@revclamp/google-ads-transparency-mcp-actor 0.0.0-stage → 1.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.
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * Model Context Protocol (MCP) JSON-RPC 2.0 Server (src/mcp_server.mjs)
3
+ *
4
+ * Exposes three deterministic tools sharing the Google Ads Transparency RPC engine:
5
+ * - `search_google_ads_transparency`: Disambiguates advertiser entities and extracts
6
+ * normalized ad creatives (`limit <= 25`, `pageToken` continuation, full JSON-RPC
7
+ * UTF-8 envelope `<= 30,000` bytes with 4-tier surrogate-safe degradation and
8
+ * automatic mid-page cursor rewind when items are popped for byte ceiling).
9
+ * - `resolve_google_ads_advertiser`: Resolves a brand name or domain into ranked
10
+ * verified `AR...` legal entities and ad-volume buckets (`includeSubsidiaries: true`).
11
+ * - `get_google_ads_creative_detail`: Fetches detailed metadata (`LookupService/GetCreativeById`)
12
+ * for a specific `(advertiserId, creativeId)` pair, including regional presence and legal name.
13
+ */
14
+
15
+ import {
16
+ resolveAdvertiserEntities,
17
+ getCreativeDetailsById,
18
+ ADVERTISER_ID_RE,
19
+ CREATIVE_ID_RE,
20
+ ISO_COUNTRY_RE,
21
+ MAX_QUERY_LENGTH,
22
+ DEFAULT_RUN_DEADLINE_MS,
23
+ decodePaginationCursor,
24
+ validateBoundedIntegerParam,
25
+ computeRewoundCursorForDeliveredPrefix,
26
+ } from './rpc_client.mjs';
27
+ import { runGoogleAdsTransparencyActor } from './apify_actor.mjs';
28
+
29
+ export const MCP_MAX_ENVELOPE_BYTES = 30000;
30
+ export const MCP_MAX_ITEMS_LIMIT = 25;
31
+ export const MCP_MAX_REQ_ID_BYTES = 256;
32
+
33
+ export const SUPPORTED_MCP_PROTOCOL_VERSIONS = Object.freeze([
34
+ '2025-06-18',
35
+ '2025-03-26',
36
+ '2024-11-05',
37
+ ]);
38
+
39
+ const RAW_BRAND_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-labelledby="rc-title"><title id="rc-title">RevClamp AdPulse</title><defs><linearGradient id="rc-fill" gradientUnits="userSpaceOnUse" x1="32" y1="6" x2="32" y2="58"><stop offset="0" stop-color="#111C38"/><stop offset="1" stop-color="#0B1020"/></linearGradient><linearGradient id="rc-edge" gradientUnits="userSpaceOnUse" x1="11" y1="6" x2="53" y2="58"><stop offset="0" stop-color="#10B981"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="#0B1020"/><path d="M32 6L53 13.5V31C53 44 44.5 52.5 32 58C19.5 52.5 11 44 11 31V13.5Z" fill="url(#rc-fill)" stroke="url(#rc-edge)" stroke-width="2" stroke-linejoin="round"/><path d="M38.5 36.5L44.5 42.5" stroke="#F59E0B" stroke-width="4" stroke-linecap="round" fill="none"/><circle cx="30.5" cy="28.5" r="12.5" fill="#0B1020"/><path d="M30.5 17.5V39.5M19.5 28.5H41.5" stroke="#06B6D4" stroke-opacity="0.35" stroke-width="1.2" fill="none"/><circle cx="30.5" cy="28.5" r="6" stroke="#06B6D4" stroke-opacity="0.5" stroke-width="1.2" fill="none"/><path d="M30.5 28.5V17.5A11 11 0 0 1 40.03 23Z" fill="#10B981" fill-opacity="0.38"/><path d="M30.5 28.5V17.5" stroke="#10B981" stroke-width="1.6" stroke-linecap="round" fill="none"/><circle cx="34" cy="22.4" r="4" fill="#F59E0B" fill-opacity="0.25"/><circle cx="34" cy="22.4" r="2.4" fill="#F59E0B"/><circle cx="25.5" cy="33" r="4.2" fill="#10B981"/><path d="M23.4 33.2L24.9 34.7L27.7 31.4" stroke="#0B1020" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="30.5" cy="28.5" r="12.5" stroke="#F8FAFC" stroke-width="2.5" fill="none"/></svg>`;
40
+
41
+ export const ENCODED_BRAND_ICON_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(RAW_BRAND_ICON_SVG)}`;
42
+
43
+ export const MCP_SERVER_INFO = Object.freeze({
44
+ name: 'google-ads-transparency-mcp-actor',
45
+ title: 'RevClamp AdPulse - For Google Ads Transparency Center (MCP & Apify Actor)',
46
+ version: '1.0.0',
47
+ description:
48
+ 'Find verified Google Ads advertiser entities and extract active or historical ad creatives (Text, Image, Video), preview links, and run dates. Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.',
49
+ // Forward-compatible MCP Registry & Glama metadata extensions (ignored by older stdio clients)
50
+ websiteUrl: 'https://revclamp.com',
51
+ icons: [
52
+ {
53
+ src: ENCODED_BRAND_ICON_DATA_URI,
54
+ mimeType: 'image/svg+xml',
55
+ sizes: ['any'],
56
+ },
57
+ {
58
+ src: 'https://revclamp.com/apple-touch-icon.png',
59
+ mimeType: 'image/png',
60
+ sizes: ['180x180'],
61
+ },
62
+ {
63
+ src: 'https://revclamp.com/favicon.png',
64
+ mimeType: 'image/png',
65
+ sizes: ['32x32'],
66
+ },
67
+ ],
68
+ });
69
+
70
+ const READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
71
+ readOnlyHint: true,
72
+ idempotentHint: true,
73
+ destructiveHint: false,
74
+ openWorldHint: true,
75
+ });
76
+
77
+ export const MCP_TOOLS_MANIFEST = Object.freeze([
78
+ {
79
+ name: 'search_google_ads_transparency',
80
+ title: 'Search Google Ads Transparency Creatives',
81
+ description:
82
+ 'Resolves a brand name or domain (e.g. "nike.com", "Stripe", "shopify.com") or explicit advertiserId (AR...) into verified Google Ads Transparency legal entities and extracts active and historical ad creatives (Text, Image, Video), preview URLs, and first/last seen timestamps. Requires either query or advertiserId.',
83
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ query: {
88
+ title: 'Brand Name or Domain',
89
+ type: 'string',
90
+ maxLength: 512,
91
+ description: 'Brand name or domain to search (e.g. "nike.com", "Stripe", "Booking.com").',
92
+ },
93
+ advertiserId: {
94
+ title: 'Advertiser ID (Optional)',
95
+ type: 'string',
96
+ description:
97
+ 'Optional canonical Google Ads Transparency Advertiser ID (e.g. "AR16735076323512287233").',
98
+ },
99
+ countryCode: {
100
+ title: 'Country Filter (Optional)',
101
+ type: 'string',
102
+ description: 'Optional 2-letter ISO country code filter (e.g. "US").',
103
+ },
104
+ formatFilter: {
105
+ title: 'Ad Format',
106
+ type: 'string',
107
+ enum: ['ALL', 'TEXT', 'IMAGE', 'VIDEO'],
108
+ default: 'ALL',
109
+ description: 'Optional creative format filter.',
110
+ },
111
+ includeSubsidiaries: {
112
+ title: 'Include Regional Subsidiaries',
113
+ type: 'boolean',
114
+ default: true,
115
+ description: 'Include up to 3 verified regional subsidiaries matching the brand.',
116
+ },
117
+ includeCreativeDetails: {
118
+ title: 'Include Creative Region & Legal Details',
119
+ type: 'boolean',
120
+ default: false,
121
+ description:
122
+ 'When true, enriches up to 5 returned creatives with GetCreativeById regional presence and legal entity details.',
123
+ },
124
+ maxEntities: {
125
+ title: 'Max Advertiser Entities',
126
+ type: 'integer',
127
+ minimum: 1,
128
+ maximum: 3,
129
+ default: 3,
130
+ description: 'Maximum number of verified advertiser entities to query (1 to 3).',
131
+ },
132
+ limit: {
133
+ title: 'Max Creatives per Call',
134
+ type: 'integer',
135
+ minimum: 1,
136
+ maximum: 25,
137
+ default: 15,
138
+ description: 'Maximum number of creatives to return in a single MCP tool call (1 to 25).',
139
+ },
140
+ maxResults: {
141
+ title: 'Max Creatives per Call (Legacy Alias)',
142
+ type: 'integer',
143
+ minimum: 1,
144
+ maximum: 25,
145
+ deprecated: true,
146
+ description: 'Deprecated alias for limit (1 to 25).',
147
+ },
148
+ pageToken: {
149
+ title: 'Next-Page Cursor Token (Optional)',
150
+ type: 'string',
151
+ maxLength: 2048,
152
+ description:
153
+ 'Optional opaque continuation cursor (upstream token or composite rcp1_ token, max 2048 chars) returned by a previous call.',
154
+ },
155
+ },
156
+ additionalProperties: false,
157
+ },
158
+ },
159
+ {
160
+ name: 'resolve_google_ads_advertiser',
161
+ title: 'Resolve Google Ads Advertiser Entities',
162
+ description:
163
+ 'Disambiguates a brand name or domain into ranked verified Google Ads Transparency advertiser entities (AR...), regional codes, and verified ad-volume buckets without fetching individual creatives.',
164
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
165
+ inputSchema: {
166
+ type: 'object',
167
+ properties: {
168
+ query: {
169
+ title: 'Brand Name or Domain',
170
+ type: 'string',
171
+ maxLength: 512,
172
+ description: 'Brand name or domain to resolve (e.g. "nike.com", "Stripe", "Apple").',
173
+ },
174
+ countryCode: {
175
+ title: 'Preferred Country (Optional)',
176
+ type: 'string',
177
+ description: 'Optional 2-letter ISO country code preference (e.g. "US").',
178
+ },
179
+ includeSubsidiaries: {
180
+ title: 'Include Regional Subsidiaries',
181
+ type: 'boolean',
182
+ default: true,
183
+ description: 'When true, returns up to 3 verified regional legal entities.',
184
+ },
185
+ maxEntities: {
186
+ title: 'Max Advertiser Entities',
187
+ type: 'integer',
188
+ minimum: 1,
189
+ maximum: 3,
190
+ default: 3,
191
+ description: 'Maximum number of verified regional legal entities to select (1 to 3).',
192
+ },
193
+ },
194
+ required: ['query'],
195
+ additionalProperties: false,
196
+ },
197
+ },
198
+ {
199
+ name: 'get_google_ads_creative_detail',
200
+ title: 'Get Google Ads Creative Detail',
201
+ description:
202
+ 'Fetches detailed metadata for a single verified Google Ads Transparency creative (AR... + CR...), including regional presence (Geo Target Criterion IDs mapped to ISO countries), advertiser legal name, and preview assets.',
203
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ advertiserId: {
208
+ title: 'Advertiser ID',
209
+ type: 'string',
210
+ description:
211
+ 'Canonical Google Ads Transparency Advertiser ID (e.g. "AR16735076323512287233").',
212
+ },
213
+ creativeId: {
214
+ title: 'Creative ID',
215
+ type: 'string',
216
+ description:
217
+ 'Canonical Google Ads Transparency Creative ID (e.g. "CR02320987639562076161").',
218
+ },
219
+ },
220
+ required: ['advertiserId', 'creativeId'],
221
+ additionalProperties: false,
222
+ },
223
+ },
224
+ ]);
225
+
226
+ /**
227
+ * Slices a UTF-16 string to fit within `maxUtf8Bytes` without splitting surrogate pairs
228
+ * or multi-byte UTF-8 sequences.
229
+ *
230
+ * @param {string} rawStr
231
+ * @param {number} maxUtf8Bytes
232
+ * @returns {string}
233
+ */
234
+ export function sliceUtf8Safe(rawStr, maxUtf8Bytes) {
235
+ const s = String(rawStr ?? '');
236
+ if (!s || maxUtf8Bytes <= 0) return '';
237
+ if (Buffer.byteLength(s, 'utf8') <= maxUtf8Bytes) return s;
238
+
239
+ let out = '';
240
+ let usedBytes = 0;
241
+ for (const codePointChar of s) {
242
+ const charBytes = Buffer.byteLength(codePointChar, 'utf8');
243
+ if (usedBytes + charBytes > maxUtf8Bytes) {
244
+ break;
245
+ }
246
+ out += codePointChar;
247
+ usedBytes += charBytes;
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Enforces the 30,000-byte UTF-8 MCP payload ceiling (`<= 32,768` bytes) across the
254
+ * **entire serialized JSON-RPC 2.0 response envelope** (including `reqId`, `isError`,
255
+ * duplicated `content[0].text` + `structuredContent`, and JSON escaping) via a 4-tier
256
+ * deterministic degradation pipeline. When items are popped (`omittedCount > 0`),
257
+ * rewinds `pagination.nextPageToken` via `cursorResumeContext` so the caller can
258
+ * resume from the first omitted item without data loss.
259
+ *
260
+ * @param {object} payload
261
+ * @param {number} [maxBytes=30000]
262
+ * @param {{reqId?: string|number|null, isError?: boolean, cursorResumeContext?: object|null}} [envelopeContext]
263
+ * @param {boolean} [isErrorArg=false]
264
+ * @returns {object}
265
+ */
266
+ export function truncateMcpResponseEnvelope(
267
+ payload,
268
+ maxBytes = MCP_MAX_ENVELOPE_BYTES,
269
+ envelopeContext = {},
270
+ isErrorArg = false
271
+ ) {
272
+ const safeMaxBytes = Math.min(32768, Math.max(2048, Number(maxBytes) || MCP_MAX_ENVELOPE_BYTES));
273
+ const isContextObj =
274
+ envelopeContext !== null &&
275
+ typeof envelopeContext === 'object' &&
276
+ !Array.isArray(envelopeContext);
277
+ const reqId = isContextObj
278
+ ? envelopeContext.reqId !== undefined
279
+ ? envelopeContext.reqId
280
+ : 999999
281
+ : envelopeContext !== undefined
282
+ ? envelopeContext
283
+ : 999999;
284
+ const isError = isContextObj ? Boolean(envelopeContext.isError) : Boolean(isErrorArg);
285
+
286
+ const rawClone = JSON.parse(JSON.stringify(payload ?? {}));
287
+ const cursorResumeContext =
288
+ (isContextObj && envelopeContext.cursorResumeContext) ||
289
+ rawClone.pagination?.cursorResumeContext ||
290
+ null;
291
+ if (rawClone.pagination && typeof rawClone.pagination === 'object') {
292
+ delete rawClone.pagination.cursorResumeContext;
293
+ }
294
+
295
+ const originalItemCount = Array.isArray(rawClone.items) ? rawClone.items.length : 0;
296
+ const originalNextPageToken = rawClone.pagination?.nextPageToken || null;
297
+ const originalPaginationStatus = rawClone.pagination?.paginationStatus || 'EXHAUSTED';
298
+
299
+ const buildOrderedPayload = (cand, tier, omittedCount) => {
300
+ const truncated = tier > 0;
301
+ const returnedCount = Array.isArray(cand.items)
302
+ ? cand.items.length
303
+ : Array.isArray(cand.selectedEntities)
304
+ ? cand.selectedEntities.length
305
+ : 0;
306
+
307
+ if (cand.pagination && typeof cand.pagination === 'object') {
308
+ delete cand.pagination.cursorResumeContext;
309
+ if (omittedCount > 0 && cursorResumeContext) {
310
+ const rewoundToken = computeRewoundCursorForDeliveredPrefix(
311
+ cursorResumeContext,
312
+ returnedCount,
313
+ originalNextPageToken
314
+ );
315
+ cand.pagination.nextPageToken = rewoundToken;
316
+ cand.pagination.hasMore = Boolean(rewoundToken) || omittedCount > 0;
317
+ cand.pagination.paginationStatus = rewoundToken
318
+ ? 'TRUNCATED_HAS_MORE_MID_PAGE'
319
+ : 'TRUNCATED_AT_BYTE_CEILING';
320
+ }
321
+ if (cand.deliveredCount !== undefined && Array.isArray(cand.items)) {
322
+ cand.deliveredCount = cand.items.length;
323
+ }
324
+ }
325
+
326
+ const summary = truncated
327
+ ? `Truncated response to fit ${safeMaxBytes}-byte MCP ceiling (tier ${tier}, returned ${returnedCount}, omitted ${omittedCount} item(s)).`
328
+ : `OK (${returnedCount} item(s) within ${safeMaxBytes}-byte MCP budget).`;
329
+
330
+ return {
331
+ summary,
332
+ truncatedForMcpByteCeiling: truncated,
333
+ truncationTier: tier,
334
+ omittedItemCount: omittedCount,
335
+ ...cand,
336
+ };
337
+ };
338
+
339
+ const measureFullRpcBytes = (structuredObj) => {
340
+ const textStr = JSON.stringify(structuredObj);
341
+ const fullRpcEnvelope = {
342
+ jsonrpc: '2.0',
343
+ id: reqId,
344
+ result: {
345
+ isError,
346
+ content: [{ type: 'text', text: textStr }],
347
+ structuredContent: structuredObj,
348
+ },
349
+ };
350
+ return Buffer.byteLength(JSON.stringify(fullRpcEnvelope), 'utf8');
351
+ };
352
+
353
+ // Tier 0: Fits untouched
354
+ const tier0Obj = buildOrderedPayload(rawClone, 0, 0);
355
+ if (measureFullRpcBytes(tier0Obj) <= safeMaxBytes) {
356
+ return tier0Obj;
357
+ }
358
+
359
+ // Tier 1: Trim candidate lists, cap archiveImageUrls to 2 per item, and pop tail items down to >= 1
360
+ const work = JSON.parse(JSON.stringify(rawClone));
361
+ if (Array.isArray(work.allCandidates) && work.allCandidates.length > 3) {
362
+ work.allCandidates = work.allCandidates.slice(0, 3);
363
+ }
364
+ if (Array.isArray(work.items)) {
365
+ for (const item of work.items) {
366
+ if (Array.isArray(item.archiveImageUrls) && item.archiveImageUrls.length > 2) {
367
+ item.archiveImageUrls = item.archiveImageUrls.slice(0, 2);
368
+ }
369
+ item.previewUrlOmitted = false;
370
+ }
371
+ }
372
+
373
+ while (Array.isArray(work.items) && work.items.length > 1) {
374
+ const omitted = Math.max(0, originalItemCount - work.items.length);
375
+ const candidate = buildOrderedPayload(work, 1, omitted);
376
+ if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
377
+ return candidate;
378
+ }
379
+ work.items.pop();
380
+ }
381
+
382
+ {
383
+ const omitted = Math.max(
384
+ 0,
385
+ originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
386
+ );
387
+ const candidate = buildOrderedPayload(work, 1, omitted);
388
+ if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
389
+ return candidate;
390
+ }
391
+ }
392
+
393
+ // Tier 2: Omit previewUrl cleanly (previewUrl: null, previewUrlOmitted: true), clear archiveImageUrls, and trim heavy ad copy fields
394
+ if (Array.isArray(work.items)) {
395
+ for (const item of work.items) {
396
+ if (item.previewUrl) {
397
+ item.previewUrl = null;
398
+ item.previewUrlOmitted = true;
399
+ }
400
+ item.archiveImageUrls = [];
401
+ if (item.rawDetailSummary) {
402
+ delete item.rawDetailSummary;
403
+ }
404
+ if (item.creativeDetail) {
405
+ delete item.creativeDetail;
406
+ }
407
+ if (item.untrustedAdContent && typeof item.untrustedAdContent === 'object') {
408
+ delete item.untrustedAdContent.headline;
409
+ delete item.untrustedAdContent.bodyText;
410
+ delete item.untrustedAdContent.landingPageUrl;
411
+ item.untrustedAdContent.omittedFields = ['headline', 'bodyText', 'landingPageUrl'];
412
+ }
413
+ if (typeof item.advertiserName === 'string') {
414
+ item.advertiserName = sliceUtf8Safe(item.advertiserName, 120);
415
+ }
416
+ if (typeof item.advertiserLegalName === 'string') {
417
+ item.advertiserLegalName = sliceUtf8Safe(item.advertiserLegalName, 120);
418
+ }
419
+ }
420
+ }
421
+ if (work.detail && typeof work.detail === 'object') {
422
+ if (work.detail.previewUrl) {
423
+ work.detail.previewUrl = null;
424
+ work.detail.previewUrlOmitted = true;
425
+ }
426
+ work.detail.archiveImageUrls = [];
427
+ if (typeof work.detail.advertiserLegalName === 'string') {
428
+ work.detail.advertiserLegalName = sliceUtf8Safe(work.detail.advertiserLegalName, 120);
429
+ }
430
+ if (Array.isArray(work.detail.regionalPresence) && work.detail.regionalPresence.length > 5) {
431
+ work.detail.regionalPresence = work.detail.regionalPresence.slice(0, 5);
432
+ }
433
+ }
434
+ if (Array.isArray(work.allCandidates)) {
435
+ work.allCandidates = work.allCandidates.slice(0, 1);
436
+ }
437
+
438
+ {
439
+ const omitted = Math.max(
440
+ 0,
441
+ originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
442
+ );
443
+ const candidate = buildOrderedPayload(work, 2, omitted);
444
+ if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
445
+ return candidate;
446
+ }
447
+ }
448
+
449
+ // Tier 3: Single compact stub item & single selectedEntity
450
+ if (Array.isArray(work.items) && work.items.length > 0) {
451
+ const first = work.items[0];
452
+ work.items = [
453
+ {
454
+ advertiserId: first.advertiserId,
455
+ advertiserName: sliceUtf8Safe(
456
+ first.advertiserName || first.advertiserLegalName || first.advertiserId,
457
+ 64
458
+ ),
459
+ country: first.country || 'ALL',
460
+ creativeId: first.creativeId,
461
+ format: first.format || 'UNKNOWN',
462
+ previewUrl: null,
463
+ previewUrlOmitted: true,
464
+ archiveImageUrls: [],
465
+ transparencyUrl: first.transparencyUrl,
466
+ },
467
+ ];
468
+ }
469
+ if (work.detail && typeof work.detail === 'object') {
470
+ work.detail = {
471
+ advertiserId: work.detail.advertiserId,
472
+ creativeId: work.detail.creativeId,
473
+ advertiserLegalName: sliceUtf8Safe(
474
+ work.detail.advertiserLegalName || work.detail.advertiserId,
475
+ 64
476
+ ),
477
+ format: work.detail.format || 'UNKNOWN',
478
+ variationCount: work.detail.variationCount ?? 1,
479
+ regionalPresence: Array.isArray(work.detail.regionalPresence)
480
+ ? work.detail.regionalPresence.slice(0, 3)
481
+ : [],
482
+ };
483
+ }
484
+ if (Array.isArray(work.selectedEntities) && work.selectedEntities.length > 1) {
485
+ work.selectedEntities = work.selectedEntities.slice(0, 1);
486
+ }
487
+ delete work.allCandidates;
488
+ delete work.queryMeta;
489
+
490
+ {
491
+ const omitted = Math.max(
492
+ 0,
493
+ originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
494
+ );
495
+ const candidate = buildOrderedPayload(work, 3, omitted);
496
+ if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
497
+ return candidate;
498
+ }
499
+ }
500
+
501
+ // Tier 4: Metadata compaction (guaranteed <= safeMaxBytes)
502
+ if (Array.isArray(work.items)) {
503
+ work.items = [];
504
+ }
505
+ if (work.detail && typeof work.detail === 'object') {
506
+ work.detail = {
507
+ advertiserId: sliceUtf8Safe(work.detail.advertiserId, 32),
508
+ creativeId: sliceUtf8Safe(work.detail.creativeId, 32),
509
+ advertiserLegalName: sliceUtf8Safe(
510
+ work.detail.advertiserLegalName || work.detail.advertiserId,
511
+ 48
512
+ ),
513
+ format: sliceUtf8Safe(work.detail.format || 'UNKNOWN', 16),
514
+ };
515
+ }
516
+ if (Array.isArray(work.selectedEntities)) {
517
+ work.selectedEntities = work.selectedEntities.slice(0, 1).map((e) => ({
518
+ advertiserId: e.advertiserId,
519
+ advertiserName: sliceUtf8Safe(e.advertiserName || e.advertiserId, 48),
520
+ regionCode: e.regionCode || 'ALL',
521
+ }));
522
+ }
523
+ if (work.primaryEntity && typeof work.primaryEntity === 'object') {
524
+ work.primaryEntity = {
525
+ advertiserId: work.primaryEntity.advertiserId,
526
+ advertiserName: sliceUtf8Safe(
527
+ work.primaryEntity.advertiserName || work.primaryEntity.advertiserId,
528
+ 48
529
+ ),
530
+ regionCode: work.primaryEntity.regionCode || 'ALL',
531
+ };
532
+ }
533
+ if (typeof work.reason === 'string') {
534
+ work.reason = sliceUtf8Safe(work.reason, 160);
535
+ }
536
+
537
+ const tier4Candidate = buildOrderedPayload(work, 4, originalItemCount);
538
+ if (measureFullRpcBytes(tier4Candidate) <= safeMaxBytes) {
539
+ return tier4Candidate;
540
+ }
541
+
542
+ // Emergency minimal stub if oversized top-level custom properties remain
543
+ return buildOrderedPayload(
544
+ {
545
+ ok: Boolean(work.ok),
546
+ status: sliceUtf8Safe(work.status || 'OK', 32),
547
+ items: [],
548
+ },
549
+ 4,
550
+ originalItemCount
551
+ );
552
+ }
553
+
554
+ /**
555
+ * Validates `reqId` per JSON-RPC 2.0 and enforces `<= 256` UTF-8 byte ceiling.
556
+ *
557
+ * @param {unknown} rawId
558
+ * @returns {{valid: boolean, id: string|number|null}}
559
+ */
560
+ function validateJsonRpcRequestId(rawId) {
561
+ if (rawId === null) {
562
+ return { valid: true, id: null };
563
+ }
564
+ if (typeof rawId === 'string') {
565
+ if (Buffer.byteLength(rawId, 'utf8') > MCP_MAX_REQ_ID_BYTES) {
566
+ return { valid: false, id: null };
567
+ }
568
+ return { valid: true, id: rawId };
569
+ }
570
+ if (typeof rawId === 'number' && Number.isFinite(rawId)) {
571
+ const strLen = Buffer.byteLength(String(rawId), 'utf8');
572
+ if (strLen > MCP_MAX_REQ_ID_BYTES) {
573
+ return { valid: false, id: null };
574
+ }
575
+ return { valid: true, id: rawId };
576
+ }
577
+ return { valid: false, id: null };
578
+ }
579
+
580
+ const SEARCH_ALLOWED_ARG_KEYS = new Set([
581
+ 'query',
582
+ 'advertiserId',
583
+ 'countryCode',
584
+ 'formatFilter',
585
+ 'includeSubsidiaries',
586
+ 'includeCreativeDetails',
587
+ 'includeAdCopy',
588
+ 'maxEntities',
589
+ 'limit',
590
+ 'maxResults',
591
+ 'maxPages',
592
+ 'pageToken',
593
+ ]);
594
+
595
+ const RESOLVE_ALLOWED_ARG_KEYS = new Set([
596
+ 'query',
597
+ 'countryCode',
598
+ 'includeSubsidiaries',
599
+ 'maxEntities',
600
+ ]);
601
+
602
+ const DETAIL_ALLOWED_ARG_KEYS = new Set(['advertiserId', 'creativeId', 'includeAdCopy']);
603
+
604
+ /**
605
+ * Validates MCP tool arguments strictly before invoking engine functions (`-32602 Invalid params`).
606
+ *
607
+ * @param {string} toolName
608
+ * @param {unknown} rawArgs
609
+ * @returns {{ok: boolean, args?: object, message?: string}}
610
+ */
611
+ export function validateMcpToolArguments(toolName, rawArgs) {
612
+ if (rawArgs !== undefined && (!rawArgs || typeof rawArgs !== 'object' || Array.isArray(rawArgs))) {
613
+ return { ok: false, message: 'Tool arguments must be a JSON object' };
614
+ }
615
+ const args = rawArgs || {};
616
+ const allowedSet =
617
+ toolName === 'search_google_ads_transparency'
618
+ ? SEARCH_ALLOWED_ARG_KEYS
619
+ : toolName === 'resolve_google_ads_advertiser'
620
+ ? RESOLVE_ALLOWED_ARG_KEYS
621
+ : DETAIL_ALLOWED_ARG_KEYS;
622
+
623
+ for (const key of Object.keys(args)) {
624
+ if (!allowedSet.has(key)) {
625
+ return {
626
+ ok: false,
627
+ message: `Unknown argument '${sliceUtf8Safe(key, 48)}' for tool '${toolName}'`,
628
+ };
629
+ }
630
+ }
631
+
632
+ if (toolName === 'get_google_ads_creative_detail') {
633
+ if (
634
+ typeof args.advertiserId !== 'string' ||
635
+ !ADVERTISER_ID_RE.test(args.advertiserId.trim())
636
+ ) {
637
+ return {
638
+ ok: false,
639
+ message: 'Invalid advertiserId: expected canonical AR... format (e.g. AR16735076323512287233)',
640
+ };
641
+ }
642
+ if (typeof args.creativeId !== 'string' || !CREATIVE_ID_RE.test(args.creativeId.trim())) {
643
+ return {
644
+ ok: false,
645
+ message: 'Invalid creativeId: expected canonical CR... format (e.g. CR02320987639562076161)',
646
+ };
647
+ }
648
+ if (args.includeAdCopy !== undefined && typeof args.includeAdCopy !== 'boolean') {
649
+ return {
650
+ ok: false,
651
+ message: 'Invalid includeAdCopy: expected a boolean',
652
+ };
653
+ }
654
+ return { ok: true, args };
655
+ }
656
+
657
+ if (args.countryCode !== undefined && args.countryCode !== null && args.countryCode !== '') {
658
+ if (
659
+ typeof args.countryCode !== 'string' ||
660
+ !ISO_COUNTRY_RE.test(args.countryCode.trim().toUpperCase())
661
+ ) {
662
+ return {
663
+ ok: false,
664
+ message: 'Invalid countryCode: expected a 2-letter ISO 3166-1 alpha-2 country code',
665
+ };
666
+ }
667
+ }
668
+
669
+ if (args.includeSubsidiaries !== undefined && typeof args.includeSubsidiaries !== 'boolean') {
670
+ return {
671
+ ok: false,
672
+ message: 'Invalid includeSubsidiaries: expected a boolean',
673
+ };
674
+ }
675
+
676
+ if (
677
+ args.includeCreativeDetails !== undefined &&
678
+ typeof args.includeCreativeDetails !== 'boolean'
679
+ ) {
680
+ return {
681
+ ok: false,
682
+ message: 'Invalid includeCreativeDetails: expected a boolean',
683
+ };
684
+ }
685
+
686
+ if (args.includeAdCopy !== undefined && typeof args.includeAdCopy !== 'boolean') {
687
+ return {
688
+ ok: false,
689
+ message: 'Invalid includeAdCopy: expected a boolean',
690
+ };
691
+ }
692
+
693
+ if (args.maxPages !== undefined) {
694
+ const check = validateBoundedIntegerParam(args.maxPages, 'maxPages', 1, 5, 5, false);
695
+ if (!check.ok) {
696
+ return { ok: false, message: check.reason };
697
+ }
698
+ }
699
+
700
+ if (args.maxEntities !== undefined) {
701
+ const check = validateBoundedIntegerParam(args.maxEntities, 'maxEntities', 1, 3, 3, false);
702
+ if (!check.ok) {
703
+ return { ok: false, message: check.reason };
704
+ }
705
+ }
706
+
707
+ if (toolName === 'resolve_google_ads_advertiser') {
708
+ if (typeof args.query !== 'string' || !args.query.trim()) {
709
+ return {
710
+ ok: false,
711
+ message: 'Missing or invalid required parameter: query (non-empty string)',
712
+ };
713
+ }
714
+ if (args.query.trim().length > MAX_QUERY_LENGTH) {
715
+ return {
716
+ ok: false,
717
+ message: `Invalid query: exceeds maximum length of ${MAX_QUERY_LENGTH} characters`,
718
+ };
719
+ }
720
+ return { ok: true, args };
721
+ }
722
+
723
+ // search_google_ads_transparency
724
+ const hasQuery = typeof args.query === 'string' && args.query.trim().length > 0;
725
+ const hasAdvId = typeof args.advertiserId === 'string' && args.advertiserId.trim().length > 0;
726
+
727
+ if (args.query !== undefined && typeof args.query !== 'string') {
728
+ return { ok: false, message: 'Invalid query: expected a string' };
729
+ }
730
+ if (hasQuery && args.query.trim().length > MAX_QUERY_LENGTH) {
731
+ return {
732
+ ok: false,
733
+ message: `Invalid query: exceeds maximum length of ${MAX_QUERY_LENGTH} characters`,
734
+ };
735
+ }
736
+ if (args.advertiserId !== undefined && typeof args.advertiserId !== 'string') {
737
+ return { ok: false, message: 'Invalid advertiserId: expected a string' };
738
+ }
739
+ if (!hasQuery && !hasAdvId) {
740
+ return {
741
+ ok: false,
742
+ message: 'Either query (brand/domain) or advertiserId (AR...) is required',
743
+ };
744
+ }
745
+ if (hasAdvId && !ADVERTISER_ID_RE.test(args.advertiserId.trim())) {
746
+ return {
747
+ ok: false,
748
+ message: 'Invalid advertiserId: expected canonical AR... format (e.g. AR16735076323512287233)',
749
+ };
750
+ }
751
+
752
+ if (args.formatFilter !== undefined) {
753
+ if (
754
+ typeof args.formatFilter !== 'string' ||
755
+ !['ALL', 'TEXT', 'IMAGE', 'VIDEO'].includes(args.formatFilter.trim().toUpperCase())
756
+ ) {
757
+ return {
758
+ ok: false,
759
+ message: 'Invalid formatFilter: expected ALL, TEXT, IMAGE, or VIDEO',
760
+ };
761
+ }
762
+ }
763
+
764
+ if (args.limit !== undefined) {
765
+ const check = validateBoundedIntegerParam(
766
+ args.limit,
767
+ 'limit',
768
+ 1,
769
+ MCP_MAX_ITEMS_LIMIT,
770
+ 15,
771
+ false
772
+ );
773
+ if (!check.ok) {
774
+ return { ok: false, message: check.reason };
775
+ }
776
+ }
777
+
778
+ if (args.maxResults !== undefined) {
779
+ const check = validateBoundedIntegerParam(
780
+ args.maxResults,
781
+ 'maxResults',
782
+ 1,
783
+ MCP_MAX_ITEMS_LIMIT,
784
+ 15,
785
+ false
786
+ );
787
+ if (!check.ok) {
788
+ return { ok: false, message: check.reason };
789
+ }
790
+ }
791
+
792
+ if (args.pageToken !== undefined && args.pageToken !== null && args.pageToken !== '') {
793
+ const cursorCheck = decodePaginationCursor(args.pageToken);
794
+ if (!cursorCheck.ok) {
795
+ return { ok: false, message: cursorCheck.reason };
796
+ }
797
+ }
798
+
799
+ return { ok: true, args };
800
+ }
801
+
802
+ /**
803
+ * Handles a single JSON-RPC 2.0 MCP request (`initialize`, `ping`, `tools/list`, `tools/call`).
804
+ * Enforces JSON-RPC 2.0 structure validation prior to notification suppression and separates
805
+ * error codes `-32600`, `-32601`, `-32602`, and `-32603`.
806
+ *
807
+ * @param {object} request
808
+ * @param {object} [options]
809
+ * @returns {Promise<object|null>}
810
+ */
811
+ export async function handleMcpJsonRpcRequest(request, options = {}) {
812
+ // Step 1: Validate base JSON-RPC 2.0 request envelope BEFORE notification check
813
+ if (
814
+ !request ||
815
+ typeof request !== 'object' ||
816
+ Array.isArray(request) ||
817
+ request.jsonrpc !== '2.0' ||
818
+ typeof request.method !== 'string' ||
819
+ !request.method.trim()
820
+ ) {
821
+ return {
822
+ jsonrpc: '2.0',
823
+ id: null,
824
+ error: {
825
+ code: -32600,
826
+ message: 'Invalid JSON-RPC 2.0 request',
827
+ },
828
+ };
829
+ }
830
+
831
+ const hasIdProperty =
832
+ Object.prototype.hasOwnProperty.call(request, 'id') && request.id !== undefined;
833
+
834
+ if (hasIdProperty) {
835
+ const idCheck = validateJsonRpcRequestId(request.id);
836
+ if (!idCheck.valid) {
837
+ return {
838
+ jsonrpc: '2.0',
839
+ id: null,
840
+ error: {
841
+ code: -32600,
842
+ message: `Invalid JSON-RPC 2.0 request id (must be string, finite number, or null <= ${MCP_MAX_REQ_ID_BYTES} bytes)`,
843
+ },
844
+ };
845
+ }
846
+ }
847
+
848
+ // Step 2: JSON-RPC 2.0 notifications (no `id` member) must NEVER emit a response object
849
+ if (!hasIdProperty) {
850
+ return null;
851
+ }
852
+
853
+ const reqId = request.id;
854
+ const method = request.method.trim();
855
+
856
+ if (method === 'ping') {
857
+ return {
858
+ jsonrpc: '2.0',
859
+ id: reqId,
860
+ result: {},
861
+ };
862
+ }
863
+
864
+ if (method === 'initialize') {
865
+ const clientVersion = String(request.params?.protocolVersion ?? '').trim();
866
+ const negotiatedVersion = SUPPORTED_MCP_PROTOCOL_VERSIONS.includes(clientVersion)
867
+ ? clientVersion
868
+ : clientVersion
869
+ ? SUPPORTED_MCP_PROTOCOL_VERSIONS[0]
870
+ : '2024-11-05';
871
+
872
+ return {
873
+ jsonrpc: '2.0',
874
+ id: reqId,
875
+ result: {
876
+ protocolVersion: negotiatedVersion,
877
+ capabilities: {
878
+ tools: { listChanged: false },
879
+ },
880
+ serverInfo: MCP_SERVER_INFO,
881
+ },
882
+ };
883
+ }
884
+
885
+ if (method === 'tools/list') {
886
+ return {
887
+ jsonrpc: '2.0',
888
+ id: reqId,
889
+ result: {
890
+ tools: MCP_TOOLS_MANIFEST,
891
+ },
892
+ };
893
+ }
894
+
895
+ if (method === 'tools/call') {
896
+ if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
897
+ return {
898
+ jsonrpc: '2.0',
899
+ id: reqId,
900
+ error: {
901
+ code: -32602,
902
+ message: 'Invalid params: expected an object with tool name and arguments',
903
+ },
904
+ };
905
+ }
906
+
907
+ const rawToolName = String(request.params.name ?? '');
908
+ const safeReflectedToolName = sliceUtf8Safe(rawToolName, 64);
909
+
910
+ if (
911
+ rawToolName !== 'search_google_ads_transparency' &&
912
+ rawToolName !== 'resolve_google_ads_advertiser' &&
913
+ rawToolName !== 'get_google_ads_creative_detail'
914
+ ) {
915
+ return {
916
+ jsonrpc: '2.0',
917
+ id: reqId,
918
+ error: {
919
+ code: -32601,
920
+ message: `Unknown MCP tool '${safeReflectedToolName}'`,
921
+ },
922
+ };
923
+ }
924
+
925
+ const argValidation = validateMcpToolArguments(rawToolName, request.params.arguments);
926
+ if (!argValidation.ok) {
927
+ return {
928
+ jsonrpc: '2.0',
929
+ id: reqId,
930
+ error: {
931
+ code: -32602,
932
+ message: `Invalid params: ${argValidation.message}`,
933
+ },
934
+ };
935
+ }
936
+
937
+ const args = argValidation.args;
938
+ const maxBytes = options.maxPayloadBytes || MCP_MAX_ENVELOPE_BYTES;
939
+ const deadlineAtMs = Date.now() + (Number(options.deadlineMs) || DEFAULT_RUN_DEADLINE_MS);
940
+
941
+ try {
942
+ if (rawToolName === 'get_google_ads_creative_detail') {
943
+ const res = await getCreativeDetailsById({
944
+ advertiserId: args.advertiserId,
945
+ creativeId: args.creativeId,
946
+ includeAdCopy: args.includeAdCopy === true,
947
+ fetchImpl: options.fetchImpl,
948
+ timeoutMs: options.timeoutMs,
949
+ maxRetries: options.maxRetries,
950
+ deadlineAtMs,
951
+ });
952
+
953
+ const isError = !res.ok;
954
+ const detailRecord = res.detail || res.creative || null;
955
+ const structured = truncateMcpResponseEnvelope(
956
+ {
957
+ ok: res.ok,
958
+ status: res.status,
959
+ reason: res.reason,
960
+ detail: detailRecord,
961
+ items: detailRecord ? [detailRecord] : [],
962
+ },
963
+ maxBytes,
964
+ { reqId, isError }
965
+ );
966
+
967
+ return {
968
+ jsonrpc: '2.0',
969
+ id: reqId,
970
+ result: {
971
+ isError,
972
+ content: [{ type: 'text', text: JSON.stringify(structured) }],
973
+ structuredContent: structured,
974
+ },
975
+ };
976
+ }
977
+
978
+ if (rawToolName === 'resolve_google_ads_advertiser') {
979
+ const res = await resolveAdvertiserEntities({
980
+ query: args.query,
981
+ countryCode: args.countryCode,
982
+ includeSubsidiaries: args.includeSubsidiaries !== false,
983
+ maxEntities: args.maxEntities ?? 3,
984
+ fetchImpl: options.fetchImpl,
985
+ timeoutMs: options.timeoutMs,
986
+ maxRetries: options.maxRetries,
987
+ deadlineAtMs,
988
+ });
989
+
990
+ const isError = !res.ok;
991
+ const structured = truncateMcpResponseEnvelope(
992
+ {
993
+ ok: res.ok,
994
+ status: res.status,
995
+ reason: res.reason,
996
+ queryMeta: res.queryMeta,
997
+ primaryEntity: res.primaryEntity,
998
+ selectedEntities: res.selectedEntities,
999
+ allCandidates: res.allCandidates.slice(0, 10),
1000
+ },
1001
+ maxBytes,
1002
+ { reqId, isError }
1003
+ );
1004
+
1005
+ return {
1006
+ jsonrpc: '2.0',
1007
+ id: reqId,
1008
+ result: {
1009
+ isError,
1010
+ content: [{ type: 'text', text: JSON.stringify(structured) }],
1011
+ structuredContent: structured,
1012
+ },
1013
+ };
1014
+ }
1015
+
1016
+ // search_google_ads_transparency
1017
+ const requestedLimit = Number(args.limit ?? args.maxResults ?? 15);
1018
+ const clampedLimit = Math.min(MCP_MAX_ITEMS_LIMIT, Math.max(1, requestedLimit));
1019
+
1020
+ const actorRes = await runGoogleAdsTransparencyActor(
1021
+ {
1022
+ query: args.query,
1023
+ advertiserId: args.advertiserId,
1024
+ countryCode: args.countryCode,
1025
+ formatFilter: args.formatFilter || 'ALL',
1026
+ includeSubsidiaries: args.includeSubsidiaries !== false,
1027
+ includeCreativeDetails: args.includeCreativeDetails === true,
1028
+ includeAdCopy: args.includeAdCopy === true,
1029
+ maxEntities: args.maxEntities ?? 3,
1030
+ maxResults: clampedLimit,
1031
+ maxPages: args.maxPages,
1032
+ pageToken: args.pageToken,
1033
+ timeoutMs: options.timeoutMs,
1034
+ maxRetries: options.maxRetries,
1035
+ },
1036
+ {
1037
+ fetchImpl: options.fetchImpl,
1038
+ }
1039
+ );
1040
+
1041
+ const isError = !actorRes.ok;
1042
+ const cursorResumeContext =
1043
+ actorRes.cursorResumeContext || actorRes.pagination?.cursorResumeContext || null;
1044
+ const cleanPagination = actorRes.pagination ? { ...actorRes.pagination } : undefined;
1045
+ if (cleanPagination) {
1046
+ delete cleanPagination.cursorResumeContext;
1047
+ }
1048
+
1049
+ const structured = truncateMcpResponseEnvelope(
1050
+ {
1051
+ ok: actorRes.ok,
1052
+ status: actorRes.status,
1053
+ reason: actorRes.reason,
1054
+ billingMode: actorRes.billingMode,
1055
+ selectedEntities: actorRes.selectedEntities,
1056
+ extractedCount: actorRes.extractedCount,
1057
+ deliveredCount: actorRes.deliveredCount,
1058
+ billableCount: actorRes.billableCount,
1059
+ chargedEventCount: actorRes.chargedEventCount,
1060
+ pagination: cleanPagination,
1061
+ ...(Array.isArray(actorRes.warnings) && actorRes.warnings.length > 0
1062
+ ? { warnings: actorRes.warnings }
1063
+ : {}),
1064
+ items: actorRes.items,
1065
+ telemetry: actorRes.telemetry,
1066
+ },
1067
+ maxBytes,
1068
+ { reqId, isError, cursorResumeContext }
1069
+ );
1070
+
1071
+ return {
1072
+ jsonrpc: '2.0',
1073
+ id: reqId,
1074
+ result: {
1075
+ isError,
1076
+ content: [{ type: 'text', text: JSON.stringify(structured) }],
1077
+ structuredContent: structured,
1078
+ },
1079
+ };
1080
+ } catch (internalErr) {
1081
+ return {
1082
+ jsonrpc: '2.0',
1083
+ id: reqId,
1084
+ error: {
1085
+ code: -32603,
1086
+ message: `Internal error: ${sliceUtf8Safe(internalErr?.message || String(internalErr), 200)}`,
1087
+ },
1088
+ };
1089
+ }
1090
+ }
1091
+
1092
+ return {
1093
+ jsonrpc: '2.0',
1094
+ id: reqId,
1095
+ error: {
1096
+ code: -32601,
1097
+ message: `Unsupported JSON-RPC method '${sliceUtf8Safe(method, 64)}'`,
1098
+ },
1099
+ };
1100
+ }