@revclamp/google-ads-transparency-mcp 0.0.0-stage → 1.0.9
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/.actor/actor.json +78 -0
- package/.actor/input_schema.json +79 -0
- package/.well-known/glama.json +6 -0
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/actor_config.json +29 -0
- package/assets/icon-180.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon.svg +26 -0
- package/assets/playground.css +468 -0
- package/assets/playground.js +817 -0
- package/glama.json +6 -0
- package/index.mjs +316 -0
- package/package.json +55 -4
- package/server.json +45 -0
- package/src/apify_actor.mjs +2208 -0
- package/src/egress_guard.mjs +102 -0
- package/src/mcp_server.mjs +1171 -0
- package/src/rpc_client.mjs +3925 -0
- package/src/web_preview_server.mjs +420 -0
|
@@ -0,0 +1,1171 @@
|
|
|
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" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#111C38"/><stop offset="100%" stop-color="#090E1D"/></linearGradient><linearGradient id="rc-edge" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#10B981"/><stop offset="100%" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" fill="#0B1020"/><path d="M32 10L50 16.5V31C50 42.5 42.5 49.5 32 54C21.5 49.5 14 42.5 14 31V16.5Z" fill="url(#rc-fill)" stroke="url(#rc-edge)" stroke-width="2.4" stroke-linejoin="round"/><path d="M32 12.8L47.4 18.4V30.8C47.4 40.4 41 46.7 32 50.8C23 46.7 16.6 40.4 16.6 30.8V18.4Z" fill="none" stroke="#F59E0B" stroke-opacity="0.45" stroke-width="0.9" stroke-linejoin="round"/><line x1="35.8" y1="34.4" x2="40.6" y2="39.2" stroke="#F59E0B" stroke-width="3.2" stroke-linecap="round"/><circle cx="29.4" cy="28" r="9.4" fill="#0B1020"/><path d="M29.4 19.6V36.4M21 28H37.8" stroke="#06B6D4" stroke-opacity="0.35" stroke-width="1"/><circle cx="29.4" cy="28" r="4.8" fill="none" stroke="#06B6D4" stroke-opacity="0.5" stroke-width="1"/><path d="M29.4 28V19.6A8.4 8.4 0 0 1 36.67 23.8Z" fill="#10B981" fill-opacity="0.38"/><line x1="29.4" y1="28" x2="29.4" y2="19.6" stroke="#10B981" stroke-width="1.4" stroke-linecap="round"/><circle cx="32.2" cy="23.4" r="2" fill="#F59E0B"/><circle cx="25.5" cy="31.3" r="3.5" fill="#10B981"/><path d="M23.9 31.4L25.1 32.6L27.3 30.1" fill="none" stroke="#0B1020" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><circle cx="29.4" cy="28" r="9.4" fill="none" stroke="#F8FAFC" stroke-width="2"/></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',
|
|
45
|
+
title: 'RevClamp AdPulse - Google Ads Transparency Scraper & MCP',
|
|
46
|
+
version: '1.0.9',
|
|
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://raw.githubusercontent.com/lhphong09t2/revclamp-mcp/main/products/google-ads-transparency-mcp/assets/icon-180.png',
|
|
59
|
+
mimeType: 'image/png',
|
|
60
|
+
sizes: ['180x180'],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
src: 'https://raw.githubusercontent.com/lhphong09t2/revclamp-mcp/main/products/google-ads-transparency-mcp/assets/icon-32.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 parsedMaxBytes =
|
|
273
|
+
maxBytes !== undefined && maxBytes !== null && Number.isFinite(Number(maxBytes))
|
|
274
|
+
? Number(maxBytes)
|
|
275
|
+
: MCP_MAX_ENVELOPE_BYTES;
|
|
276
|
+
const safeMaxBytes = Math.min(32768, Math.max(2048, parsedMaxBytes));
|
|
277
|
+
const isContextObj =
|
|
278
|
+
envelopeContext !== null &&
|
|
279
|
+
typeof envelopeContext === 'object' &&
|
|
280
|
+
!Array.isArray(envelopeContext);
|
|
281
|
+
const reqId = isContextObj
|
|
282
|
+
? envelopeContext.reqId !== undefined
|
|
283
|
+
? envelopeContext.reqId
|
|
284
|
+
: 999999
|
|
285
|
+
: envelopeContext !== undefined
|
|
286
|
+
? envelopeContext
|
|
287
|
+
: 999999;
|
|
288
|
+
const isError = isContextObj ? Boolean(envelopeContext.isError) : Boolean(isErrorArg);
|
|
289
|
+
|
|
290
|
+
const rawClone = JSON.parse(JSON.stringify(payload ?? {}));
|
|
291
|
+
const cursorResumeContext =
|
|
292
|
+
(isContextObj && envelopeContext.cursorResumeContext) ||
|
|
293
|
+
rawClone.pagination?.cursorResumeContext ||
|
|
294
|
+
null;
|
|
295
|
+
if (rawClone.pagination && typeof rawClone.pagination === 'object') {
|
|
296
|
+
delete rawClone.pagination.cursorResumeContext;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const originalItemCount = Array.isArray(rawClone.items) ? rawClone.items.length : 0;
|
|
300
|
+
const originalNextPageToken = rawClone.pagination?.nextPageToken || null;
|
|
301
|
+
const originalPaginationStatus = rawClone.pagination?.paginationStatus || 'EXHAUSTED';
|
|
302
|
+
|
|
303
|
+
const buildOrderedPayload = (cand, tier, omittedCount) => {
|
|
304
|
+
const truncated = tier > 0;
|
|
305
|
+
const returnedCount = Array.isArray(cand.items)
|
|
306
|
+
? cand.items.length
|
|
307
|
+
: Array.isArray(cand.selectedEntities)
|
|
308
|
+
? cand.selectedEntities.length
|
|
309
|
+
: 0;
|
|
310
|
+
|
|
311
|
+
if (cand.pagination && typeof cand.pagination === 'object') {
|
|
312
|
+
delete cand.pagination.cursorResumeContext;
|
|
313
|
+
if ((omittedCount > 0 || tier >= 4) && cursorResumeContext) {
|
|
314
|
+
const rewoundToken = computeRewoundCursorForDeliveredPrefix(
|
|
315
|
+
cursorResumeContext,
|
|
316
|
+
returnedCount,
|
|
317
|
+
originalNextPageToken
|
|
318
|
+
);
|
|
319
|
+
cand.pagination.nextPageToken = rewoundToken;
|
|
320
|
+
cand.pagination.hasMore = Boolean(rewoundToken);
|
|
321
|
+
cand.pagination.paginationStatus = rewoundToken
|
|
322
|
+
? 'TRUNCATED_HAS_MORE_MID_PAGE'
|
|
323
|
+
: 'TRUNCATED_AT_BYTE_CEILING';
|
|
324
|
+
} else {
|
|
325
|
+
cand.pagination.hasMore = Boolean(cand.pagination.nextPageToken);
|
|
326
|
+
}
|
|
327
|
+
if (cand.deliveredCount !== undefined && Array.isArray(cand.items)) {
|
|
328
|
+
cand.deliveredCount = cand.items.length;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const summary = truncated
|
|
333
|
+
? `Truncated response to fit ${safeMaxBytes}-byte MCP ceiling (tier ${tier}, returned ${returnedCount}, omitted ${omittedCount} item(s)).`
|
|
334
|
+
: `OK (${returnedCount} item(s) within ${safeMaxBytes}-byte MCP budget).`;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
summary,
|
|
338
|
+
truncatedForMcpByteCeiling: truncated,
|
|
339
|
+
truncationTier: tier,
|
|
340
|
+
omittedItemCount: omittedCount,
|
|
341
|
+
...cand,
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const measureFullRpcBytes = (structuredObj) => {
|
|
346
|
+
const textStr = JSON.stringify(structuredObj);
|
|
347
|
+
const fullRpcEnvelope = {
|
|
348
|
+
jsonrpc: '2.0',
|
|
349
|
+
id: reqId,
|
|
350
|
+
result: {
|
|
351
|
+
isError,
|
|
352
|
+
content: [{ type: 'text', text: textStr }],
|
|
353
|
+
structuredContent: structuredObj,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
return Buffer.byteLength(JSON.stringify(fullRpcEnvelope), 'utf8');
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Tier 0: Fits untouched
|
|
360
|
+
const tier0Obj = buildOrderedPayload(rawClone, 0, 0);
|
|
361
|
+
if (measureFullRpcBytes(tier0Obj) <= safeMaxBytes) {
|
|
362
|
+
return tier0Obj;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Tier 1: Trim candidate lists, cap archiveImageUrls to 2 per item, and pop tail items down to >= 1
|
|
366
|
+
const work = JSON.parse(JSON.stringify(rawClone));
|
|
367
|
+
if (Array.isArray(work.allCandidates) && work.allCandidates.length > 3) {
|
|
368
|
+
work.allCandidates = work.allCandidates.slice(0, 3);
|
|
369
|
+
}
|
|
370
|
+
if (Array.isArray(work.items)) {
|
|
371
|
+
for (const item of work.items) {
|
|
372
|
+
if (Array.isArray(item.archiveImageUrls) && item.archiveImageUrls.length > 2) {
|
|
373
|
+
item.archiveImageUrls = item.archiveImageUrls.slice(0, 2);
|
|
374
|
+
}
|
|
375
|
+
item.previewUrlOmitted = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
while (Array.isArray(work.items) && work.items.length > 1) {
|
|
380
|
+
const omitted = Math.max(0, originalItemCount - work.items.length);
|
|
381
|
+
const candidate = buildOrderedPayload(work, 1, omitted);
|
|
382
|
+
if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
|
|
383
|
+
return candidate;
|
|
384
|
+
}
|
|
385
|
+
work.items.pop();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
{
|
|
389
|
+
const omitted = Math.max(
|
|
390
|
+
0,
|
|
391
|
+
originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
|
|
392
|
+
);
|
|
393
|
+
const candidate = buildOrderedPayload(work, 1, omitted);
|
|
394
|
+
if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
|
|
395
|
+
return candidate;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Tier 2: Omit previewUrl cleanly (previewUrl: null, previewUrlOmitted: true), clear archiveImageUrls, and trim heavy ad copy fields
|
|
400
|
+
if (Array.isArray(work.items)) {
|
|
401
|
+
for (const item of work.items) {
|
|
402
|
+
if (item.previewUrl) {
|
|
403
|
+
item.previewUrl = null;
|
|
404
|
+
item.previewUrlOmitted = true;
|
|
405
|
+
}
|
|
406
|
+
item.archiveImageUrls = [];
|
|
407
|
+
if (item.rawDetailSummary) {
|
|
408
|
+
delete item.rawDetailSummary;
|
|
409
|
+
}
|
|
410
|
+
if (item.creativeDetail) {
|
|
411
|
+
delete item.creativeDetail;
|
|
412
|
+
}
|
|
413
|
+
if (item.untrustedAdContent && typeof item.untrustedAdContent === 'object') {
|
|
414
|
+
delete item.untrustedAdContent.headline;
|
|
415
|
+
delete item.untrustedAdContent.bodyText;
|
|
416
|
+
delete item.untrustedAdContent.landingPageUrl;
|
|
417
|
+
item.untrustedAdContent.omittedFields = ['headline', 'bodyText', 'landingPageUrl'];
|
|
418
|
+
}
|
|
419
|
+
if (typeof item.advertiserName === 'string') {
|
|
420
|
+
item.advertiserName = sliceUtf8Safe(item.advertiserName, 120);
|
|
421
|
+
}
|
|
422
|
+
if (typeof item.advertiserLegalName === 'string') {
|
|
423
|
+
item.advertiserLegalName = sliceUtf8Safe(item.advertiserLegalName, 120);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (work.detail && typeof work.detail === 'object') {
|
|
428
|
+
if (work.detail.previewUrl) {
|
|
429
|
+
work.detail.previewUrl = null;
|
|
430
|
+
work.detail.previewUrlOmitted = true;
|
|
431
|
+
}
|
|
432
|
+
work.detail.archiveImageUrls = [];
|
|
433
|
+
if (typeof work.detail.advertiserLegalName === 'string') {
|
|
434
|
+
work.detail.advertiserLegalName = sliceUtf8Safe(work.detail.advertiserLegalName, 120);
|
|
435
|
+
}
|
|
436
|
+
if (Array.isArray(work.detail.regionalPresence) && work.detail.regionalPresence.length > 5) {
|
|
437
|
+
work.detail.regionalPresence = work.detail.regionalPresence.slice(0, 5);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (Array.isArray(work.allCandidates)) {
|
|
441
|
+
work.allCandidates = work.allCandidates.slice(0, 1);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
{
|
|
445
|
+
const omitted = Math.max(
|
|
446
|
+
0,
|
|
447
|
+
originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
|
|
448
|
+
);
|
|
449
|
+
const candidate = buildOrderedPayload(work, 2, omitted);
|
|
450
|
+
if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
|
|
451
|
+
return candidate;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Tier 3: Single compact stub item & single selectedEntity
|
|
456
|
+
if (Array.isArray(work.items) && work.items.length > 0) {
|
|
457
|
+
const first = work.items[0];
|
|
458
|
+
work.items = [
|
|
459
|
+
{
|
|
460
|
+
advertiserId: first.advertiserId,
|
|
461
|
+
advertiserName: sliceUtf8Safe(
|
|
462
|
+
first.advertiserName || first.advertiserLegalName || first.advertiserId,
|
|
463
|
+
64
|
|
464
|
+
),
|
|
465
|
+
country: first.country || 'ALL',
|
|
466
|
+
creativeId: first.creativeId,
|
|
467
|
+
format: first.format || 'UNKNOWN',
|
|
468
|
+
previewUrl: null,
|
|
469
|
+
previewUrlOmitted: true,
|
|
470
|
+
archiveImageUrls: [],
|
|
471
|
+
transparencyUrl: first.transparencyUrl,
|
|
472
|
+
},
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
if (work.detail && typeof work.detail === 'object') {
|
|
476
|
+
work.detail = {
|
|
477
|
+
advertiserId: work.detail.advertiserId,
|
|
478
|
+
creativeId: work.detail.creativeId,
|
|
479
|
+
advertiserLegalName: sliceUtf8Safe(
|
|
480
|
+
work.detail.advertiserLegalName || work.detail.advertiserId,
|
|
481
|
+
64
|
|
482
|
+
),
|
|
483
|
+
format: work.detail.format || 'UNKNOWN',
|
|
484
|
+
variationCount: work.detail.variationCount ?? 1,
|
|
485
|
+
regionalPresence: Array.isArray(work.detail.regionalPresence)
|
|
486
|
+
? work.detail.regionalPresence.slice(0, 3)
|
|
487
|
+
: [],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (Array.isArray(work.selectedEntities) && work.selectedEntities.length > 1) {
|
|
491
|
+
work.selectedEntities = work.selectedEntities.slice(0, 1);
|
|
492
|
+
}
|
|
493
|
+
delete work.allCandidates;
|
|
494
|
+
delete work.queryMeta;
|
|
495
|
+
|
|
496
|
+
{
|
|
497
|
+
const omitted = Math.max(
|
|
498
|
+
0,
|
|
499
|
+
originalItemCount - (Array.isArray(work.items) ? work.items.length : 0)
|
|
500
|
+
);
|
|
501
|
+
const candidate = buildOrderedPayload(work, 3, omitted);
|
|
502
|
+
if (measureFullRpcBytes(candidate) <= safeMaxBytes) {
|
|
503
|
+
return candidate;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Tier 4: Metadata compaction (guaranteed <= safeMaxBytes)
|
|
508
|
+
if (Array.isArray(work.items)) {
|
|
509
|
+
work.items = [];
|
|
510
|
+
}
|
|
511
|
+
if (work.detail && typeof work.detail === 'object') {
|
|
512
|
+
work.detail = {
|
|
513
|
+
advertiserId: sliceUtf8Safe(work.detail.advertiserId, 32),
|
|
514
|
+
creativeId: sliceUtf8Safe(work.detail.creativeId, 32),
|
|
515
|
+
advertiserLegalName: sliceUtf8Safe(
|
|
516
|
+
work.detail.advertiserLegalName || work.detail.advertiserId,
|
|
517
|
+
48
|
|
518
|
+
),
|
|
519
|
+
format: sliceUtf8Safe(work.detail.format || 'UNKNOWN', 16),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (Array.isArray(work.selectedEntities)) {
|
|
523
|
+
work.selectedEntities = work.selectedEntities.slice(0, 1).map((e) => ({
|
|
524
|
+
advertiserId: e.advertiserId,
|
|
525
|
+
advertiserName: sliceUtf8Safe(e.advertiserName || e.advertiserId, 48),
|
|
526
|
+
regionCode: e.regionCode || 'ALL',
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
if (work.primaryEntity && typeof work.primaryEntity === 'object') {
|
|
530
|
+
work.primaryEntity = {
|
|
531
|
+
advertiserId: work.primaryEntity.advertiserId,
|
|
532
|
+
advertiserName: sliceUtf8Safe(
|
|
533
|
+
work.primaryEntity.advertiserName || work.primaryEntity.advertiserId,
|
|
534
|
+
48
|
|
535
|
+
),
|
|
536
|
+
regionCode: work.primaryEntity.regionCode || 'ALL',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (typeof work.reason === 'string') {
|
|
540
|
+
work.reason = sliceUtf8Safe(work.reason, 160);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const tier4Candidate = buildOrderedPayload(work, 4, originalItemCount);
|
|
544
|
+
if (measureFullRpcBytes(tier4Candidate) <= safeMaxBytes) {
|
|
545
|
+
return tier4Candidate;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Emergency minimal stub if oversized top-level custom properties remain
|
|
549
|
+
const emergencyWithPagination = buildOrderedPayload(
|
|
550
|
+
{
|
|
551
|
+
ok: Boolean(work.ok),
|
|
552
|
+
status: sliceUtf8Safe(work.status || 'OK', 32),
|
|
553
|
+
items: [],
|
|
554
|
+
...(rawClone.pagination
|
|
555
|
+
? {
|
|
556
|
+
pagination: {
|
|
557
|
+
nextPageToken: originalNextPageToken,
|
|
558
|
+
hasMore: Boolean(originalNextPageToken),
|
|
559
|
+
paginationStatus: 'TRUNCATED_AT_BYTE_CEILING',
|
|
560
|
+
},
|
|
561
|
+
}
|
|
562
|
+
: {}),
|
|
563
|
+
},
|
|
564
|
+
4,
|
|
565
|
+
originalItemCount
|
|
566
|
+
);
|
|
567
|
+
if (measureFullRpcBytes(emergencyWithPagination) <= safeMaxBytes) {
|
|
568
|
+
return emergencyWithPagination;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const emergencyNoToken = {
|
|
572
|
+
summary: `Truncated response to fit ${safeMaxBytes}-byte MCP ceiling (tier 4, returned 0, omitted ${originalItemCount} item(s)).`,
|
|
573
|
+
truncatedForMcpByteCeiling: true,
|
|
574
|
+
truncationTier: 4,
|
|
575
|
+
omittedItemCount: originalItemCount,
|
|
576
|
+
ok: Boolean(work.ok),
|
|
577
|
+
status: sliceUtf8Safe(work.status || 'OK', 32),
|
|
578
|
+
items: [],
|
|
579
|
+
...(rawClone.pagination
|
|
580
|
+
? {
|
|
581
|
+
pagination: {
|
|
582
|
+
nextPageToken: null,
|
|
583
|
+
hasMore: false,
|
|
584
|
+
paginationStatus: 'TRUNCATED_AT_BYTE_CEILING',
|
|
585
|
+
},
|
|
586
|
+
}
|
|
587
|
+
: {}),
|
|
588
|
+
};
|
|
589
|
+
if (measureFullRpcBytes(emergencyNoToken) <= safeMaxBytes) {
|
|
590
|
+
return emergencyNoToken;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const ultraMinimalWithPagination = {
|
|
594
|
+
ok: Boolean(work.ok),
|
|
595
|
+
status: sliceUtf8Safe(work.status || 'OK', 16),
|
|
596
|
+
truncatedForMcpByteCeiling: true,
|
|
597
|
+
truncationTier: 4,
|
|
598
|
+
omittedItemCount: originalItemCount,
|
|
599
|
+
items: [],
|
|
600
|
+
...(rawClone.pagination
|
|
601
|
+
? {
|
|
602
|
+
pagination: {
|
|
603
|
+
nextPageToken: null,
|
|
604
|
+
hasMore: false,
|
|
605
|
+
},
|
|
606
|
+
}
|
|
607
|
+
: {}),
|
|
608
|
+
};
|
|
609
|
+
if (measureFullRpcBytes(ultraMinimalWithPagination) <= safeMaxBytes) {
|
|
610
|
+
return ultraMinimalWithPagination;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
ok: Boolean(work.ok),
|
|
615
|
+
truncatedForMcpByteCeiling: true,
|
|
616
|
+
truncationTier: 4,
|
|
617
|
+
items: [],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Validates `reqId` per JSON-RPC 2.0 and enforces `<= 256` UTF-8 byte ceiling.
|
|
623
|
+
*
|
|
624
|
+
* @param {unknown} rawId
|
|
625
|
+
* @returns {{valid: boolean, id: string|number|null}}
|
|
626
|
+
*/
|
|
627
|
+
function validateJsonRpcRequestId(rawId) {
|
|
628
|
+
if (rawId === null) {
|
|
629
|
+
return { valid: true, id: null };
|
|
630
|
+
}
|
|
631
|
+
if (typeof rawId === 'string') {
|
|
632
|
+
if (
|
|
633
|
+
/[\u0000-\u001F\u007F]/.test(rawId) ||
|
|
634
|
+
Buffer.byteLength(rawId, 'utf8') > MCP_MAX_REQ_ID_BYTES ||
|
|
635
|
+
Buffer.byteLength(JSON.stringify(rawId), 'utf8') > MCP_MAX_REQ_ID_BYTES + 2
|
|
636
|
+
) {
|
|
637
|
+
return { valid: false, id: null };
|
|
638
|
+
}
|
|
639
|
+
return { valid: true, id: rawId };
|
|
640
|
+
}
|
|
641
|
+
if (typeof rawId === 'number' && Number.isFinite(rawId)) {
|
|
642
|
+
const strLen = Buffer.byteLength(String(rawId), 'utf8');
|
|
643
|
+
if (strLen > MCP_MAX_REQ_ID_BYTES) {
|
|
644
|
+
return { valid: false, id: null };
|
|
645
|
+
}
|
|
646
|
+
return { valid: true, id: rawId };
|
|
647
|
+
}
|
|
648
|
+
return { valid: false, id: null };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const SEARCH_ALLOWED_ARG_KEYS = new Set([
|
|
652
|
+
'query',
|
|
653
|
+
'advertiserId',
|
|
654
|
+
'countryCode',
|
|
655
|
+
'formatFilter',
|
|
656
|
+
'includeSubsidiaries',
|
|
657
|
+
'includeCreativeDetails',
|
|
658
|
+
'includeAdCopy',
|
|
659
|
+
'maxEntities',
|
|
660
|
+
'limit',
|
|
661
|
+
'maxResults',
|
|
662
|
+
'maxPages',
|
|
663
|
+
'pageToken',
|
|
664
|
+
]);
|
|
665
|
+
|
|
666
|
+
const RESOLVE_ALLOWED_ARG_KEYS = new Set([
|
|
667
|
+
'query',
|
|
668
|
+
'countryCode',
|
|
669
|
+
'includeSubsidiaries',
|
|
670
|
+
'maxEntities',
|
|
671
|
+
]);
|
|
672
|
+
|
|
673
|
+
const DETAIL_ALLOWED_ARG_KEYS = new Set(['advertiserId', 'creativeId', 'includeAdCopy']);
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Validates MCP tool arguments strictly before invoking engine functions (`-32602 Invalid params`).
|
|
677
|
+
*
|
|
678
|
+
* @param {string} toolName
|
|
679
|
+
* @param {unknown} rawArgs
|
|
680
|
+
* @returns {{ok: boolean, args?: object, message?: string}}
|
|
681
|
+
*/
|
|
682
|
+
export function validateMcpToolArguments(toolName, rawArgs) {
|
|
683
|
+
if (rawArgs !== undefined && (!rawArgs || typeof rawArgs !== 'object' || Array.isArray(rawArgs))) {
|
|
684
|
+
return { ok: false, message: 'Tool arguments must be a JSON object' };
|
|
685
|
+
}
|
|
686
|
+
const args = rawArgs || {};
|
|
687
|
+
const allowedSet =
|
|
688
|
+
toolName === 'search_google_ads_transparency'
|
|
689
|
+
? SEARCH_ALLOWED_ARG_KEYS
|
|
690
|
+
: toolName === 'resolve_google_ads_advertiser'
|
|
691
|
+
? RESOLVE_ALLOWED_ARG_KEYS
|
|
692
|
+
: DETAIL_ALLOWED_ARG_KEYS;
|
|
693
|
+
|
|
694
|
+
for (const key of Object.keys(args)) {
|
|
695
|
+
if (!allowedSet.has(key)) {
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
message: `Unknown argument '${sliceUtf8Safe(key, 48)}' for tool '${toolName}'`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (toolName === 'get_google_ads_creative_detail') {
|
|
704
|
+
if (
|
|
705
|
+
typeof args.advertiserId !== 'string' ||
|
|
706
|
+
!ADVERTISER_ID_RE.test(args.advertiserId.trim())
|
|
707
|
+
) {
|
|
708
|
+
return {
|
|
709
|
+
ok: false,
|
|
710
|
+
message: 'Invalid advertiserId: expected canonical AR... format (e.g. AR16735076323512287233)',
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (typeof args.creativeId !== 'string' || !CREATIVE_ID_RE.test(args.creativeId.trim())) {
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
message: 'Invalid creativeId: expected canonical CR... format (e.g. CR02320987639562076161)',
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (args.includeAdCopy !== undefined && typeof args.includeAdCopy !== 'boolean') {
|
|
720
|
+
return {
|
|
721
|
+
ok: false,
|
|
722
|
+
message: 'Invalid includeAdCopy: expected a boolean',
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return { ok: true, args };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (args.countryCode !== undefined && args.countryCode !== null && args.countryCode !== '') {
|
|
729
|
+
if (
|
|
730
|
+
typeof args.countryCode !== 'string' ||
|
|
731
|
+
!ISO_COUNTRY_RE.test(args.countryCode.trim().toUpperCase())
|
|
732
|
+
) {
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
message: 'Invalid countryCode: expected a 2-letter ISO 3166-1 alpha-2 country code',
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (args.includeSubsidiaries !== undefined && typeof args.includeSubsidiaries !== 'boolean') {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
message: 'Invalid includeSubsidiaries: expected a boolean',
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (
|
|
748
|
+
args.includeCreativeDetails !== undefined &&
|
|
749
|
+
typeof args.includeCreativeDetails !== 'boolean'
|
|
750
|
+
) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
message: 'Invalid includeCreativeDetails: expected a boolean',
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (args.includeAdCopy !== undefined && typeof args.includeAdCopy !== 'boolean') {
|
|
758
|
+
return {
|
|
759
|
+
ok: false,
|
|
760
|
+
message: 'Invalid includeAdCopy: expected a boolean',
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (args.maxPages !== undefined) {
|
|
765
|
+
const check = validateBoundedIntegerParam(args.maxPages, 'maxPages', 1, 5, 5, false);
|
|
766
|
+
if (!check.ok) {
|
|
767
|
+
return { ok: false, message: check.reason };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (args.maxEntities !== undefined) {
|
|
772
|
+
const check = validateBoundedIntegerParam(args.maxEntities, 'maxEntities', 1, 3, 3, false);
|
|
773
|
+
if (!check.ok) {
|
|
774
|
+
return { ok: false, message: check.reason };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (toolName === 'resolve_google_ads_advertiser') {
|
|
779
|
+
if (typeof args.query !== 'string' || !args.query.trim()) {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
message: 'Missing or invalid required parameter: query (non-empty string)',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
if (args.query.trim().length > MAX_QUERY_LENGTH) {
|
|
786
|
+
return {
|
|
787
|
+
ok: false,
|
|
788
|
+
message: `Invalid query: exceeds maximum length of ${MAX_QUERY_LENGTH} characters`,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return { ok: true, args };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// search_google_ads_transparency
|
|
795
|
+
const hasQuery = typeof args.query === 'string' && args.query.trim().length > 0;
|
|
796
|
+
const hasAdvId = typeof args.advertiserId === 'string' && args.advertiserId.trim().length > 0;
|
|
797
|
+
|
|
798
|
+
if (args.query !== undefined && typeof args.query !== 'string') {
|
|
799
|
+
return { ok: false, message: 'Invalid query: expected a string' };
|
|
800
|
+
}
|
|
801
|
+
if (hasQuery && args.query.trim().length > MAX_QUERY_LENGTH) {
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
message: `Invalid query: exceeds maximum length of ${MAX_QUERY_LENGTH} characters`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (args.advertiserId !== undefined && typeof args.advertiserId !== 'string') {
|
|
808
|
+
return { ok: false, message: 'Invalid advertiserId: expected a string' };
|
|
809
|
+
}
|
|
810
|
+
if (!hasQuery && !hasAdvId) {
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
message: 'Either query (brand/domain) or advertiserId (AR...) is required',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (hasAdvId && !ADVERTISER_ID_RE.test(args.advertiserId.trim())) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
message: 'Invalid advertiserId: expected canonical AR... format (e.g. AR16735076323512287233)',
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (args.formatFilter !== undefined) {
|
|
824
|
+
if (
|
|
825
|
+
typeof args.formatFilter !== 'string' ||
|
|
826
|
+
!['ALL', 'TEXT', 'IMAGE', 'VIDEO'].includes(args.formatFilter.trim().toUpperCase())
|
|
827
|
+
) {
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
message: 'Invalid formatFilter: expected ALL, TEXT, IMAGE, or VIDEO',
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (args.limit !== undefined) {
|
|
836
|
+
const check = validateBoundedIntegerParam(
|
|
837
|
+
args.limit,
|
|
838
|
+
'limit',
|
|
839
|
+
1,
|
|
840
|
+
MCP_MAX_ITEMS_LIMIT,
|
|
841
|
+
15,
|
|
842
|
+
false
|
|
843
|
+
);
|
|
844
|
+
if (!check.ok) {
|
|
845
|
+
return { ok: false, message: check.reason };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (args.maxResults !== undefined) {
|
|
850
|
+
const check = validateBoundedIntegerParam(
|
|
851
|
+
args.maxResults,
|
|
852
|
+
'maxResults',
|
|
853
|
+
1,
|
|
854
|
+
MCP_MAX_ITEMS_LIMIT,
|
|
855
|
+
15,
|
|
856
|
+
false
|
|
857
|
+
);
|
|
858
|
+
if (!check.ok) {
|
|
859
|
+
return { ok: false, message: check.reason };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (args.pageToken !== undefined && args.pageToken !== null && args.pageToken !== '') {
|
|
864
|
+
const cursorCheck = decodePaginationCursor(args.pageToken);
|
|
865
|
+
if (!cursorCheck.ok) {
|
|
866
|
+
return { ok: false, message: cursorCheck.reason };
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { ok: true, args };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Handles a single JSON-RPC 2.0 MCP request (`initialize`, `ping`, `tools/list`, `tools/call`).
|
|
875
|
+
* Enforces JSON-RPC 2.0 structure validation prior to notification suppression and separates
|
|
876
|
+
* error codes `-32600`, `-32601`, `-32602`, and `-32603`.
|
|
877
|
+
*
|
|
878
|
+
* @param {object} request
|
|
879
|
+
* @param {object} [options]
|
|
880
|
+
* @returns {Promise<object|null>}
|
|
881
|
+
*/
|
|
882
|
+
export async function handleMcpJsonRpcRequest(request, options = {}) {
|
|
883
|
+
// Step 1: Validate base JSON-RPC 2.0 request envelope BEFORE notification check
|
|
884
|
+
if (
|
|
885
|
+
!request ||
|
|
886
|
+
typeof request !== 'object' ||
|
|
887
|
+
Array.isArray(request) ||
|
|
888
|
+
request.jsonrpc !== '2.0' ||
|
|
889
|
+
typeof request.method !== 'string' ||
|
|
890
|
+
!request.method.trim()
|
|
891
|
+
) {
|
|
892
|
+
return {
|
|
893
|
+
jsonrpc: '2.0',
|
|
894
|
+
id: null,
|
|
895
|
+
error: {
|
|
896
|
+
code: -32600,
|
|
897
|
+
message: 'Invalid JSON-RPC 2.0 request',
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const hasIdProperty =
|
|
903
|
+
Object.prototype.hasOwnProperty.call(request, 'id') && request.id !== undefined;
|
|
904
|
+
|
|
905
|
+
if (hasIdProperty) {
|
|
906
|
+
const idCheck = validateJsonRpcRequestId(request.id);
|
|
907
|
+
if (!idCheck.valid) {
|
|
908
|
+
return {
|
|
909
|
+
jsonrpc: '2.0',
|
|
910
|
+
id: null,
|
|
911
|
+
error: {
|
|
912
|
+
code: -32600,
|
|
913
|
+
message: `Invalid JSON-RPC 2.0 request id (must be string, finite number, or null <= ${MCP_MAX_REQ_ID_BYTES} bytes)`,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Step 2: JSON-RPC 2.0 notifications (no `id` member) must NEVER emit a response object
|
|
920
|
+
if (!hasIdProperty) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const reqId = request.id;
|
|
925
|
+
const method = request.method.trim();
|
|
926
|
+
|
|
927
|
+
if (method === 'ping') {
|
|
928
|
+
return {
|
|
929
|
+
jsonrpc: '2.0',
|
|
930
|
+
id: reqId,
|
|
931
|
+
result: {},
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (method === 'initialize') {
|
|
936
|
+
const clientVersion = String(request.params?.protocolVersion ?? '').trim();
|
|
937
|
+
const negotiatedVersion = SUPPORTED_MCP_PROTOCOL_VERSIONS.includes(clientVersion)
|
|
938
|
+
? clientVersion
|
|
939
|
+
: clientVersion
|
|
940
|
+
? SUPPORTED_MCP_PROTOCOL_VERSIONS[0]
|
|
941
|
+
: '2024-11-05';
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
jsonrpc: '2.0',
|
|
945
|
+
id: reqId,
|
|
946
|
+
result: {
|
|
947
|
+
protocolVersion: negotiatedVersion,
|
|
948
|
+
capabilities: {
|
|
949
|
+
tools: { listChanged: false },
|
|
950
|
+
},
|
|
951
|
+
serverInfo: MCP_SERVER_INFO,
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (method === 'tools/list') {
|
|
957
|
+
return {
|
|
958
|
+
jsonrpc: '2.0',
|
|
959
|
+
id: reqId,
|
|
960
|
+
result: {
|
|
961
|
+
tools: MCP_TOOLS_MANIFEST,
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (method === 'tools/call') {
|
|
967
|
+
if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
|
|
968
|
+
return {
|
|
969
|
+
jsonrpc: '2.0',
|
|
970
|
+
id: reqId,
|
|
971
|
+
error: {
|
|
972
|
+
code: -32602,
|
|
973
|
+
message: 'Invalid params: expected an object with tool name and arguments',
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const rawToolName = String(request.params.name ?? '');
|
|
979
|
+
const safeReflectedToolName = sliceUtf8Safe(rawToolName, 64);
|
|
980
|
+
|
|
981
|
+
if (
|
|
982
|
+
rawToolName !== 'search_google_ads_transparency' &&
|
|
983
|
+
rawToolName !== 'resolve_google_ads_advertiser' &&
|
|
984
|
+
rawToolName !== 'get_google_ads_creative_detail'
|
|
985
|
+
) {
|
|
986
|
+
return {
|
|
987
|
+
jsonrpc: '2.0',
|
|
988
|
+
id: reqId,
|
|
989
|
+
error: {
|
|
990
|
+
code: -32601,
|
|
991
|
+
message: `Unknown MCP tool '${safeReflectedToolName}'`,
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const argValidation = validateMcpToolArguments(rawToolName, request.params.arguments);
|
|
997
|
+
if (!argValidation.ok) {
|
|
998
|
+
return {
|
|
999
|
+
jsonrpc: '2.0',
|
|
1000
|
+
id: reqId,
|
|
1001
|
+
error: {
|
|
1002
|
+
code: -32602,
|
|
1003
|
+
message: `Invalid params: ${argValidation.message}`,
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const args = argValidation.args;
|
|
1009
|
+
const maxBytes = options.maxPayloadBytes || MCP_MAX_ENVELOPE_BYTES;
|
|
1010
|
+
const deadlineAtMs = Date.now() + (Number(options.deadlineMs) || DEFAULT_RUN_DEADLINE_MS);
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
if (rawToolName === 'get_google_ads_creative_detail') {
|
|
1014
|
+
const res = await getCreativeDetailsById({
|
|
1015
|
+
advertiserId: args.advertiserId,
|
|
1016
|
+
creativeId: args.creativeId,
|
|
1017
|
+
includeAdCopy: args.includeAdCopy === true,
|
|
1018
|
+
fetchImpl: options.fetchImpl,
|
|
1019
|
+
timeoutMs: options.timeoutMs,
|
|
1020
|
+
maxRetries: options.maxRetries,
|
|
1021
|
+
deadlineAtMs,
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const isError = !res.ok;
|
|
1025
|
+
const detailRecord = res.detail || res.creative || null;
|
|
1026
|
+
const structured = truncateMcpResponseEnvelope(
|
|
1027
|
+
{
|
|
1028
|
+
ok: res.ok,
|
|
1029
|
+
status: res.status,
|
|
1030
|
+
reason: res.reason,
|
|
1031
|
+
detail: detailRecord,
|
|
1032
|
+
items: detailRecord ? [detailRecord] : [],
|
|
1033
|
+
},
|
|
1034
|
+
maxBytes,
|
|
1035
|
+
{ reqId, isError }
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
return {
|
|
1039
|
+
jsonrpc: '2.0',
|
|
1040
|
+
id: reqId,
|
|
1041
|
+
result: {
|
|
1042
|
+
isError,
|
|
1043
|
+
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
1044
|
+
structuredContent: structured,
|
|
1045
|
+
},
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (rawToolName === 'resolve_google_ads_advertiser') {
|
|
1050
|
+
const res = await resolveAdvertiserEntities({
|
|
1051
|
+
query: args.query,
|
|
1052
|
+
countryCode: args.countryCode,
|
|
1053
|
+
includeSubsidiaries: args.includeSubsidiaries !== false,
|
|
1054
|
+
maxEntities: args.maxEntities ?? 3,
|
|
1055
|
+
fetchImpl: options.fetchImpl,
|
|
1056
|
+
timeoutMs: options.timeoutMs,
|
|
1057
|
+
maxRetries: options.maxRetries,
|
|
1058
|
+
deadlineAtMs,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
const isError = !res.ok;
|
|
1062
|
+
const structured = truncateMcpResponseEnvelope(
|
|
1063
|
+
{
|
|
1064
|
+
ok: res.ok,
|
|
1065
|
+
status: res.status,
|
|
1066
|
+
reason: res.reason,
|
|
1067
|
+
queryMeta: res.queryMeta,
|
|
1068
|
+
primaryEntity: res.primaryEntity,
|
|
1069
|
+
selectedEntities: res.selectedEntities,
|
|
1070
|
+
allCandidates: res.allCandidates.slice(0, 10),
|
|
1071
|
+
},
|
|
1072
|
+
maxBytes,
|
|
1073
|
+
{ reqId, isError }
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
jsonrpc: '2.0',
|
|
1078
|
+
id: reqId,
|
|
1079
|
+
result: {
|
|
1080
|
+
isError,
|
|
1081
|
+
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
1082
|
+
structuredContent: structured,
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// search_google_ads_transparency
|
|
1088
|
+
const requestedLimit = Number(args.limit ?? args.maxResults ?? 15);
|
|
1089
|
+
const clampedLimit = Math.min(MCP_MAX_ITEMS_LIMIT, Math.max(1, requestedLimit));
|
|
1090
|
+
|
|
1091
|
+
const actorRes = await runGoogleAdsTransparencyActor(
|
|
1092
|
+
{
|
|
1093
|
+
query: args.query,
|
|
1094
|
+
advertiserId: args.advertiserId,
|
|
1095
|
+
countryCode: args.countryCode,
|
|
1096
|
+
formatFilter: args.formatFilter || 'ALL',
|
|
1097
|
+
includeSubsidiaries: args.includeSubsidiaries !== false,
|
|
1098
|
+
includeCreativeDetails: args.includeCreativeDetails === true,
|
|
1099
|
+
includeAdCopy: args.includeAdCopy === true,
|
|
1100
|
+
maxEntities: args.maxEntities ?? 3,
|
|
1101
|
+
maxResults: clampedLimit,
|
|
1102
|
+
maxPages: args.maxPages,
|
|
1103
|
+
pageToken: args.pageToken,
|
|
1104
|
+
timeoutMs: options.timeoutMs,
|
|
1105
|
+
maxRetries: options.maxRetries,
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
fetchImpl: options.fetchImpl,
|
|
1109
|
+
}
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
const isError = !actorRes.ok;
|
|
1113
|
+
const cursorResumeContext =
|
|
1114
|
+
actorRes.cursorResumeContext || actorRes.pagination?.cursorResumeContext || null;
|
|
1115
|
+
const cleanPagination = actorRes.pagination ? { ...actorRes.pagination } : undefined;
|
|
1116
|
+
if (cleanPagination) {
|
|
1117
|
+
delete cleanPagination.cursorResumeContext;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const structured = truncateMcpResponseEnvelope(
|
|
1121
|
+
{
|
|
1122
|
+
ok: actorRes.ok,
|
|
1123
|
+
status: actorRes.status,
|
|
1124
|
+
reason: actorRes.reason,
|
|
1125
|
+
billingMode: actorRes.billingMode,
|
|
1126
|
+
selectedEntities: actorRes.selectedEntities,
|
|
1127
|
+
extractedCount: actorRes.extractedCount,
|
|
1128
|
+
deliveredCount: actorRes.deliveredCount,
|
|
1129
|
+
billableCount: actorRes.billableCount,
|
|
1130
|
+
chargedEventCount: actorRes.chargedEventCount,
|
|
1131
|
+
pagination: cleanPagination,
|
|
1132
|
+
...(Array.isArray(actorRes.warnings) && actorRes.warnings.length > 0
|
|
1133
|
+
? { warnings: actorRes.warnings }
|
|
1134
|
+
: {}),
|
|
1135
|
+
items: actorRes.items,
|
|
1136
|
+
telemetry: actorRes.telemetry,
|
|
1137
|
+
},
|
|
1138
|
+
maxBytes,
|
|
1139
|
+
{ reqId, isError, cursorResumeContext }
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
jsonrpc: '2.0',
|
|
1144
|
+
id: reqId,
|
|
1145
|
+
result: {
|
|
1146
|
+
isError,
|
|
1147
|
+
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
1148
|
+
structuredContent: structured,
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
} catch (internalErr) {
|
|
1152
|
+
return {
|
|
1153
|
+
jsonrpc: '2.0',
|
|
1154
|
+
id: reqId,
|
|
1155
|
+
error: {
|
|
1156
|
+
code: -32603,
|
|
1157
|
+
message: `Internal error: ${sliceUtf8Safe(internalErr?.message || String(internalErr), 200)}`,
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
jsonrpc: '2.0',
|
|
1165
|
+
id: reqId,
|
|
1166
|
+
error: {
|
|
1167
|
+
code: -32601,
|
|
1168
|
+
message: `Unsupported JSON-RPC method '${sliceUtf8Safe(method, 64)}'`,
|
|
1169
|
+
},
|
|
1170
|
+
};
|
|
1171
|
+
}
|