@buygolinks/mcp 0.28.0 → 0.32.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.
Files changed (3) hide show
  1. package/README.md +27 -16
  2. package/package.json +5 -7
  3. package/src/index.js +544 -79
package/src/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * Auth: BUYGOLINKS_API_KEY env var (personal access token, format bgl_…).
10
10
  * Create one at https://app.buygolinks.com/settings/account.
11
11
  */
12
+ import { readFileSync } from 'node:fs';
12
13
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
15
  import {
@@ -39,16 +40,32 @@ async function api(path, init = {}) {
39
40
  try { body = text ? JSON.parse(text) : null; } catch { body = text; }
40
41
  if (!r.ok) {
41
42
  const code = body?.error || r.statusText || `http_${r.status}`;
42
- throw new Error(`buygolinks API ${r.status}: ${code}`);
43
+ const detail = body?.message ? ` (${body.message})` : '';
44
+ throw new Error(`buygolinks API ${r.status}: ${code}${detail}`);
43
45
  }
44
46
  return body;
45
47
  }
46
48
 
49
+ // Query string from the args that are set, skipping undefined, null and ''.
50
+ function qsFrom(args, keys) {
51
+ const qs = new URLSearchParams();
52
+ for (const k of keys) {
53
+ const v = args?.[k];
54
+ if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
55
+ }
56
+ const q = qs.toString();
57
+ return q ? `?${q}` : '';
58
+ }
59
+
60
+ const PKG_VERSION = JSON.parse(
61
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
62
+ ).version;
63
+
47
64
  const TOOLS = [
48
65
  {
49
66
  name: 'create_link',
50
67
  description:
51
- 'Create a new branded short link on buygolinks.com. The link redirects to exactly the destination URL you provide — for Amazon links, include your own Associates tag in the URL (e.g. ?tag=yourtag-20); buygolinks passes it through untouched and does not rewrite or geo-route Amazon URLs. By default, the link is created under the user\'s first active custom domain (if any); pass `domain_id` to pin it to a specific one. Returns the short URL and link metadata (including `short_domain`, the hostname the link will resolve at).',
68
+ 'Create a new branded short link on buygolinks.com. The link redirects to exactly the destination URL you provide. For Amazon links, include your own Associates tag in the URL (e.g. ?tag=yourtag-20); buygolinks passes it through untouched and does not rewrite or geo-route Amazon URLs. By default, the link is created under the user\'s first active custom domain (if any); pass `domain_id` to pin it to a specific one. Returns the short URL and link metadata (including `short_domain`, the hostname the link will resolve at).',
52
69
  inputSchema: {
53
70
  type: 'object',
54
71
  properties: {
@@ -75,7 +92,7 @@ const TOOLS = [
75
92
  {
76
93
  name: 'update_link',
77
94
  description:
78
- 'Update an existing short link. Supports changing where it redirects (destination_url), renaming (title), changing the campaign source label, swapping the branded domain it resolves under (domain_id), setting a memorable custom alias (alias), archiving/unarchiving, and password-protecting the link. Pass `destination_url` to repoint the link at a new target — the short code, alias, and click history are preserved; only the redirect destination moves (the ASIN is re-extracted automatically for Amazon /dp/ URLs). Pass `alias` to give the link a memorable URL slug on its custom domain (e.g. alias="summer-sale" → https://click.example.com/summer-sale). Aliases require the link to be on a custom domain. Setting `alias` REPLACES any previous alias — the old alias stops resolving immediately and is freed for any other link to claim. The link\'s original random short code (link.code) keeps working forever regardless of alias changes. Pass alias="" or alias=null to remove the alias. Changing the link\'s `domain_id` clears the alias automatically. To set or change the password, pass `password` as a non-empty string. To remove password protection, pass an empty string for `password`. To move the link back to the platform default (buygolinks.com), pass `domain_id: null`. Returns the updated link row including `short_domain`, `alias`, and `password_protected` (boolean — the plaintext is never returned).',
95
+ 'Update an existing short link. Supports changing where it redirects (destination_url), renaming (title, which also renames the offer paired to this link\'s destination URL), changing the campaign source label, swapping the branded domain it resolves under (domain_id), setting a memorable custom alias (alias), archiving/unarchiving, and password-protecting the link. Pass `destination_url` to repoint the link at a new target. The short code, alias, and click history are preserved; only the redirect destination moves (the ASIN is re-extracted automatically for Amazon /dp/ URLs). Pass `alias` to give the link a memorable URL slug on its custom domain (e.g. alias="summer-sale" → https://click.example.com/summer-sale). Aliases require the link to be on a custom domain. Setting `alias` REPLACES any previous alias. The old alias stops resolving immediately and is freed for any other link to claim. The link\'s original random short code (link.code) keeps working forever regardless of alias changes. Pass alias="" or alias=null to remove the alias. Changing the link\'s `domain_id` clears the alias automatically. To set or change the password, pass `password` as a non-empty string. To remove password protection, pass an empty string for `password`. To move the link back to the platform default (buygolinks.com), pass `domain_id: null`. Returns the updated link row including `short_domain`, `alias`, and `password_protected` (boolean; the plaintext is never returned).',
79
96
  inputSchema: {
80
97
  type: 'object',
81
98
  properties: {
@@ -94,7 +111,7 @@ const TOOLS = [
94
111
  {
95
112
  name: 'refresh_link_image',
96
113
  description:
97
- 'Re-attempt fetching the product/preview image for an existing short link. Re-runs the same best-effort scrape used at link creation — the Amazon product-image scraper for Amazon /dp/ links, or a generic Open Graph (og:image / twitter:image) scrape for any other URL — and rehosts the result on buygolinks storage. Use this when a link was created without an image (the thumbnail shows a letter/placeholder) or to update a stale image. Returns { found: true, link } with the updated `product_image_url` on success, or { found: false } if no image could be fetched this time (the existing image, if any, is left untouched — try again later).',
114
+ 'Re-attempt fetching the product/preview image for an existing short link. Re-runs the same best-effort scrape used at link creation: the Amazon product-image scraper for Amazon /dp/ links, or a generic Open Graph (og:image / twitter:image) scrape for any other URL, then rehosts the result on buygolinks storage. Use this when a link was created without an image (the thumbnail shows a letter/placeholder) or to update a stale image. Returns { found: true, link } with the updated `product_image_url` on success, or { found: false } if no image could be fetched this time (the existing image, if any, is left untouched; try again later).',
98
115
  inputSchema: {
99
116
  type: 'object',
100
117
  properties: {
@@ -103,6 +120,141 @@ const TOOLS = [
103
120
  required: ['link_id'],
104
121
  },
105
122
  },
123
+ {
124
+ name: 'delete_link',
125
+ description:
126
+ 'Permanently delete a short link and its click history. The short URL and any alias stop resolving at once. This cannot be undone: to take a link out of use but keep its stats, use update_link with archived=true instead. Confirm with the user before calling this.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ link_id: { type: 'number', description: 'The numeric id of the link (from list_links).' },
131
+ },
132
+ required: ['link_id'],
133
+ },
134
+ },
135
+ {
136
+ name: 'get_link_pixels',
137
+ description:
138
+ 'Get which tracking pixels fire when a short link is clicked. Returns { pixel_ids, events, overridden }. When `overridden` is false the link has no selection of its own and fires ALL of the account\'s active pixels. `events` maps a pixel id to the event this link fires it as, when that differs from the pixel\'s default_event.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: { link_id: { type: 'number' } },
142
+ required: ['link_id'],
143
+ },
144
+ },
145
+ {
146
+ name: 'set_link_pixels',
147
+ description:
148
+ 'Choose which tracking pixels fire when a short link is clicked (ids from list_tracking_pixels). Replaces the link\'s whole selection. With overridden=true the link fires exactly these pixels (an empty list means none). With overridden=false, or an empty list and no overridden, the link goes back to firing every active account pixel. `events` optionally sets the event per pixel for this link, e.g. {"12": "ViewContent"}; it must be valid for that pixel\'s platform.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ link_id: { type: 'number' },
153
+ pixel_ids: { type: 'array', items: { type: 'number' } },
154
+ overridden: { type: 'boolean', description: 'Default: true when pixel_ids is not empty.' },
155
+ events: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per pixel event for this link, keyed by pixel id.' },
156
+ },
157
+ required: ['link_id', 'pixel_ids'],
158
+ },
159
+ },
160
+ {
161
+ name: 'set_link_collection',
162
+ description: 'Put a short link in a collection (from list_collections), or pass collection_id null to take it out. A link is in at most one collection.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ link_id: { type: 'number' },
167
+ collection_id: { type: ['number', 'null'] },
168
+ },
169
+ required: ['link_id', 'collection_id'],
170
+ },
171
+ },
172
+
173
+ // ─── Collections ────────────────────────────────────────────────────────
174
+ {
175
+ name: 'list_collections',
176
+ description:
177
+ 'List the user\'s collections (folders of short links) with each one\'s link and click counts, starred first. A collection can also be a ROTATOR: with rotator_enabled, https://buygolinks.com/c/{rotator_alias} sends each visitor to a random link in the collection.',
178
+ inputSchema: { type: 'object', properties: {} },
179
+ },
180
+ {
181
+ name: 'get_collection',
182
+ description:
183
+ 'Get one collection with its links (each with its short_domain and clicks) and click analytics for the window: totals (links, clicks, unique_clicks, countries), by_day, by_country, by_device, by_browser, by_referrer, by_language and the 25 most recent clicks.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ collection_id: { type: 'number' },
188
+ days: { type: 'number', description: 'Window size in days (1 to 90). Defaults to 14.' },
189
+ },
190
+ required: ['collection_id'],
191
+ },
192
+ },
193
+ {
194
+ name: 'create_collection',
195
+ description:
196
+ 'Create a collection to group short links. Add links to it with set_link_collection. Set rotator_enabled to get a rotator URL (https://buygolinks.com/c/{rotator_alias}) that sends each visitor to a random link in the collection; the alias is generated when not given.',
197
+ inputSchema: {
198
+ type: 'object',
199
+ properties: {
200
+ name: { type: 'string', description: 'Up to 80 characters.' },
201
+ description: { type: 'string', description: 'Optional, up to 280 characters.' },
202
+ color: { type: 'string', description: 'One of #ef4444 #f97316 #f59e0b #eab308 #84cc16 #22c55e #14b8a6 #3b82f6 #1d4ed8 #6d28d9 #8b5cf6 #ec4899 #be185d #92400e #0f766e #000000 #fb7185 #0d9488. Anything else becomes #ef4444.' },
203
+ starred: { type: 'boolean', description: 'Starred collections show in the app\'s Starred menu.' },
204
+ rotator_enabled: { type: 'boolean' },
205
+ rotator_alias: { type: 'string', description: 'Optional rotator path, 8 to 24 letters, numbers, dashes or underscores. 409 alias_taken when in use.' },
206
+ },
207
+ required: ['name'],
208
+ },
209
+ },
210
+ {
211
+ name: 'update_collection',
212
+ description: 'Rename a collection or change its description, color, star, or rotator. Turning the rotator off keeps its alias, so turning it back on restores the same URL.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ collection_id: { type: 'number' },
217
+ name: { type: 'string' },
218
+ description: { type: 'string' },
219
+ color: { type: 'string' },
220
+ starred: { type: 'boolean' },
221
+ rotator_enabled: { type: 'boolean' },
222
+ rotator_alias: { type: 'string', description: '8 to 24 letters, numbers, dashes or underscores.' },
223
+ },
224
+ required: ['collection_id'],
225
+ },
226
+ },
227
+ {
228
+ name: 'delete_collection',
229
+ description: 'Delete a collection. Its links are kept and simply leave the collection; its rotator URL stops working.',
230
+ inputSchema: { type: 'object', properties: { collection_id: { type: 'number' } }, required: ['collection_id'] },
231
+ },
232
+
233
+ // ─── Account ────────────────────────────────────────────────────────────
234
+ {
235
+ name: 'get_account',
236
+ description:
237
+ 'Get the signed-in account: email, plan and billing period, current_period_end and cancels_at, monthly usage (clicks_used_this_month against plan_click_limit, pageview_usage {used, limit, remaining, over, resets_at}), the report time zone (`timezone`, null means each browser uses its own), the dashboard\'s default range (tracker_range_days) and the universal postback_url.',
238
+ inputSchema: { type: 'object', properties: {} },
239
+ },
240
+ {
241
+ name: 'list_api_keys',
242
+ description: 'List the account\'s API keys: id, label, prefix (the first 8 characters after bgl_, to tell keys apart), last_used_at and created_at. The keys themselves are never returned.',
243
+ inputSchema: { type: 'object', properties: {} },
244
+ },
245
+ {
246
+ name: 'create_api_key',
247
+ description: 'Create a new API key. The full key (`token`, bgl_...) is returned ONCE and cannot be shown again, so hand it to the user straight away and tell them to store it.',
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: { label: { type: 'string', description: 'Name to tell the key apart, e.g. "Cursor on laptop".' } },
251
+ },
252
+ },
253
+ {
254
+ name: 'delete_api_key',
255
+ description: 'Revoke an API key at once. Anything using it starts getting 401s, including this MCP server if it is the key it runs on. Confirm with the user first.',
256
+ inputSchema: { type: 'object', properties: { api_key_id: { type: 'number' } }, required: ['api_key_id'] },
257
+ },
106
258
  {
107
259
  name: 'list_links',
108
260
  description:
@@ -139,7 +291,7 @@ const TOOLS = [
139
291
  {
140
292
  name: 'get_overview_stats',
141
293
  description:
142
- 'Get account-wide analytics for the user\'s branded short LINKS only: total link CLICKS, clicks by day, top countries, devices, browsers, referrers, peak hour and weekday. This covers the Links section ONLY — it does NOT include Tracker page views, site-property traffic, or campaign visits/conversions. For PAGE VIEWS (site properties) or campaign traffic use the Tracker tools instead: get_tracker_overview (clicks-vs-pageviews unified rollup), list_properties, or get_property_stats. If the user asks for "clicks AND page views" together, call BOTH this tool and get_tracker_overview and combine them — they read from separate event streams. Use this for "how are my links doing" type questions.',
294
+ 'Get account-wide analytics for the user\'s branded short LINKS only: total link CLICKS, clicks by day, top countries, devices, browsers, referrers, peak hour and weekday. This covers the Links section ONLY. It does NOT include Tracker page views, site-property traffic, or campaign visits/conversions. For PAGE VIEWS (site properties) or campaign traffic use the Tracker tools instead: get_tracker_overview (clicks-vs-pageviews unified rollup), list_properties, or get_property_stats. If the user asks for "clicks AND page views" together, call BOTH this tool and get_tracker_overview and combine them, because they read from separate event streams. Use this for "how are my links doing" type questions.',
143
295
  inputSchema: {
144
296
  type: 'object',
145
297
  properties: {
@@ -183,6 +335,31 @@ const TOOLS = [
183
335
  required: ['domain_id'],
184
336
  },
185
337
  },
338
+ {
339
+ name: 'check_domain_dns',
340
+ description:
341
+ 'Detect where a custom domain\'s DNS is hosted (Cloudflare, GoDaddy, Namecheap, Route 53, and others) from its public nameservers, and report the easiest way to point it at buygolinks. `one_click.method` is `domain_connect` (send the user to `one_click.url` to approve the change at their provider), `cloudflare_oauth` (tell the user to open Properties in the app and click Connect Cloudflare on this domain, which needs a browser), or null (the user adds the CNAME by hand, at `dns_url` when known). On Cloudflare, `one_click.token_url` is also set, for connect_domain_cloudflare when the user prefers an agent to do it.',
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: {
345
+ domain_id: { type: 'number', description: 'The numeric id of the domain (from list_domains / create_domain).' },
346
+ },
347
+ required: ['domain_id'],
348
+ },
349
+ },
350
+ {
351
+ name: 'connect_domain_cloudflare',
352
+ description:
353
+ 'Add the required CNAME to the user\'s own Cloudflare zone without a browser, using an API token they created with Zone Read and DNS Edit (form at `one_click.token_url`). Prefer the Connect Cloudflare button in the app when the user can click it, since it needs no token. The token is used for this one request and is never stored. Returns `action`: `created`, or `already_set` when the record is already correct. Never overwrites an existing record: a different record on the hostname returns error `record_exists`. Other errors: `invalid_token`, `zone_not_found`, `token_missing_dns`. Call verify_domain afterward to watch status move to active.',
354
+ inputSchema: {
355
+ type: 'object',
356
+ properties: {
357
+ domain_id: { type: 'number', description: 'The numeric id of the domain.' },
358
+ api_token: { type: 'string', description: 'The user\'s Cloudflare API token.' },
359
+ },
360
+ required: ['domain_id', 'api_token'],
361
+ },
362
+ },
186
363
  {
187
364
  name: 'delete_domain',
188
365
  description:
@@ -200,27 +377,36 @@ const TOOLS = [
200
377
  {
201
378
  name: 'get_universal_postback_url',
202
379
  description:
203
- 'Get the account\'s UNIVERSAL postback URL (Voluum-style): ONE URL that works for every campaign and every affiliate network. It matches the click by click_id alone and credits whichever campaign the click belongs to, so the user pastes it once into any network\'s S2S settings or their own webhook (n8n, Zapier, a store\'s order webhook) and never updates it again. Format: https://buygolinks.com/postback/u/{token}?clickid={clickid}&payout={payout}&currency={currency}&txid={txid} — payout/currency/txid optional; `cid` is accepted as an alias for `clickid`. Prefer this over per-campaign or per-network postback URLs unless the user explicitly wants campaign-scoped validation (the per-campaign URL rejects clicks from other campaigns with a 400).',
380
+ 'Get the account\'s UNIVERSAL postback URL (Voluum-style): ONE URL that works for every campaign and every affiliate network. It matches the click by click_id alone and credits whichever campaign the click belongs to, so the user pastes it once into any network\'s S2S settings or their own webhook (n8n, Zapier, a store\'s order webhook) and never updates it again. Format: https://buygolinks.com/postback/u/{token}?clickid={clickid}&payout={payout}&currency={currency}&txid={txid} where payout/currency/txid are optional; `cid` is accepted as an alias for `clickid`. It is the account\'s ONLY postback URL: campaigns and networks have no postback URL of their own. Conversions are deduped by txid: at most one conversion per (account, txid). A re-fired postback with the same txid is ignored, and a re-fire with a different payout updates the stored conversion\'s revenue instead of stacking a second one. With no `event` param the postback records a sale. Add `event=refund` or `event=chargeback` to subtract from revenue (matched by click, so the amount can be sent as 67 or -67, and leaving it out reverses everything booked on the click), or `event={slug}` for one of the user\'s custom events (see list_custom_events). An event that is blank or unknown gets a 400 and records nothing.',
204
381
  inputSchema: { type: 'object', properties: {} },
205
382
  },
383
+ {
384
+ name: 'set_account_timezone',
385
+ description:
386
+ 'Set the time zone the account\'s reports count days and hours in: Today and Yesterday on the dashboard, by_day, by_hour, and the day / weekday / hour report dimensions. It applies on every device and to every API call that does not pass its own `tz`. Pass an IANA name such as America/New_York, Europe/London or Asia/Tokyo, or null to go back to each browser using its own zone (API calls then count in UTC). Returns the saved value.',
387
+ inputSchema: {
388
+ type: 'object',
389
+ properties: {
390
+ timezone: { type: ['string', 'null'], description: 'IANA time zone name, or null to clear.' },
391
+ },
392
+ required: ['timezone'],
393
+ },
394
+ },
206
395
  {
207
396
  name: 'list_affiliate_networks',
208
397
  description:
209
- 'List the user\'s affiliate networks (ClickBank, Digistore24, MaxWeb, etc). Each network includes a postback_url the user pastes into the network\'s S2S settings so conversions flow back automatically. Networks also configure how the network names the click id and payout in postbacks (conversion_param, payout_param).',
398
+ 'List the user\'s affiliate networks (ClickBank, Digistore24, MaxWeb, etc). Their sales come back on the account\'s one postback URL (get_universal_postback_url); networks have no postback URL of their own. conversion_param is what the network calls the click id, and is appended to offer URLs that use the network.',
210
399
  inputSchema: { type: 'object', properties: {} },
211
400
  },
212
401
  {
213
402
  name: 'create_affiliate_network',
214
403
  description:
215
- 'Add a new affiliate network. The server mints a per-network postback URL (https://buygolinks.com/postback/n/{token}) the user pastes into the network. The postback accepts ?{conversion_param}={clickid}&{payout_param}={payout}&{currency_param}={currency}&{txid_param}={txid} — payout, currency, and txid are optional. Returns the network row including postback_url. For Amazon Associates, set amazon_tag (e.g. "yourtagid-20"): every Amazon destination link the user adds is then auto-tagged with ?tag={amazon_tag} at click time (a tag already on the URL always wins).',
404
+ 'Add a new affiliate network. Its sales come back on the account\'s one postback URL (get_universal_postback_url), not a URL of its own. Every offer using the network gets ?{conversion_param}={click_id} appended at click time unless the offer URL already has a {clickid} token. For Amazon Associates, set amazon_tag (e.g. "yourtagid-20"): every Amazon destination link the user adds is then auto-tagged with ?tag={amazon_tag} at click time (a tag already on the URL always wins).',
216
405
  inputSchema: {
217
406
  type: 'object',
218
407
  properties: {
219
408
  name: { type: 'string', description: 'Display name, e.g. "ClickBank".' },
220
- conversion_param: { type: 'string', description: 'What the network calls the click id in its postback (default "clickid").' },
221
- payout_param: { type: 'string', description: 'What the network calls the payout amount (default "payout").' },
222
- currency_param: { type: 'string', description: 'What the network calls the currency code (default "currency").' },
223
- txid_param: { type: 'string', description: 'What the network calls the transaction/order id in its postback (default "txid"). Stored on the conversion for reconciliation/dedupe.' },
409
+ conversion_param: { type: 'string', description: 'What the network calls the click id (default "clickid"). Appended to offer URLs that use this network.' },
224
410
  amazon_tag: { type: 'string', description: 'Amazon Associates tracking tag (e.g. "yourtagid-20"). When set, Amazon links are auto-converted to affiliate links by appending ?tag={amazon_tag} on each click.' },
225
411
  },
226
412
  required: ['name'],
@@ -228,16 +414,13 @@ const TOOLS = [
228
414
  },
229
415
  {
230
416
  name: 'update_affiliate_network',
231
- description: 'Update an affiliate network (rename, change postback param names, or set the Amazon Associates amazon_tag — pass an empty string to clear it). Pass archived=true to soft-archive.',
417
+ description: 'Update an affiliate network (rename, change the click id param, or set the Amazon Associates amazon_tag; pass an empty string to clear it). Pass archived=true to soft-archive.',
232
418
  inputSchema: {
233
419
  type: 'object',
234
420
  properties: {
235
421
  network_id: { type: 'number' },
236
422
  name: { type: 'string' },
237
423
  conversion_param: { type: 'string' },
238
- payout_param: { type: 'string' },
239
- currency_param: { type: 'string' },
240
- txid_param: { type: 'string' },
241
424
  amazon_tag: { type: 'string', description: 'Amazon Associates tracking tag (e.g. "yourtagid-20"); empty string clears it.' },
242
425
  archived: { type: 'boolean' },
243
426
  },
@@ -263,14 +446,14 @@ const TOOLS = [
263
446
  {
264
447
  name: 'create_offer',
265
448
  description:
266
- 'Create an offer (a destination URL: affiliate landing page, product page, etc). The URL is used exactly as given when redirecting — include any affiliate tag yourself. Link the offer to an affiliate_network_id so the resolver knows which postback param name to use when redirecting (e.g. some networks expect ?aff_sub=… instead of ?clickid=…).',
449
+ 'Create an offer (a destination URL: affiliate landing page, product page, etc). The URL is used exactly as given when redirecting. Include any affiliate tag yourself. An offer gets no short link of its own: the link that goes in an ad comes from a campaign (create_campaign), which also sets the domain. Link the offer to an affiliate_network_id so the resolver knows which postback param name to use when redirecting (e.g. some networks expect ?aff_sub=… instead of ?clickid=…).',
267
450
  inputSchema: {
268
451
  type: 'object',
269
452
  properties: {
270
453
  name: { type: 'string' },
271
454
  url: { type: 'string', description: 'Destination URL (http/https).' },
272
455
  affiliate_network_id: { type: ['number', 'null'] },
273
- payout: { type: 'number' },
456
+ payout: { type: ['number', 'null'], description: 'Default payout. A sale postback that arrives with no payout books this amount. Leave out or null for none.' },
274
457
  payout_currency: { type: 'string', description: 'ISO 4217 currency, default USD.' },
275
458
  country: { type: 'string', description: 'ISO2 country code (optional).' },
276
459
  },
@@ -279,7 +462,7 @@ const TOOLS = [
279
462
  },
280
463
  {
281
464
  name: 'update_offer',
282
- description: 'Update an offer\'s name, URL, payout, currency, country, or affiliate network. Pass archived=true to soft-archive.',
465
+ description: 'Update an offer\'s name, URL, payout, currency, country, or affiliate network. An offer has no short link of its own (the link for an ad comes from a campaign); renaming one also renames any older short link left on its URL, so the two keep one name. Pass archived=true to soft-archive.',
283
466
  inputSchema: {
284
467
  type: 'object',
285
468
  properties: {
@@ -287,7 +470,7 @@ const TOOLS = [
287
470
  name: { type: 'string' },
288
471
  url: { type: 'string' },
289
472
  affiliate_network_id: { type: ['number', 'null'] },
290
- payout: { type: 'number' },
473
+ payout: { type: ['number', 'null'], description: 'Default payout, booked on a sale postback that has none. null clears it.' },
291
474
  payout_currency: { type: 'string' },
292
475
  country: { type: 'string' },
293
476
  archived: { type: 'boolean' },
@@ -342,12 +525,100 @@ const TOOLS = [
342
525
  inputSchema: { type: 'object', properties: { lander_id: { type: 'number' } }, required: ['lander_id'] },
343
526
  },
344
527
 
528
+ // ─── Custom postback events ─────────────────────────────────────────────
529
+ {
530
+ name: 'list_custom_events',
531
+ description:
532
+ 'List the account\'s custom postback events. Sale, refund and chargeback are built in on every account (`built_in`); everything else (add to cart, upsell, lead, registration, anything) is defined by the user (`events`). Each event\'s `slug` is the value of `event=` on any postback URL. `presets` are common starting points that do not exist until created.',
533
+ inputSchema: { type: 'object', properties: {} },
534
+ },
535
+ {
536
+ name: 'create_custom_event',
537
+ description:
538
+ 'Add a custom postback event. Once created, `event={slug}` on any postback URL records it and it appears as its own column in campaign reports. The slug is derived from the label unless given, and cannot be changed later. Built-in names (purchase, sale, conversion, refund, chargeback) and tracker names (visit, offer_click, ...) are refused. With counts_revenue true, the event adds its payout to revenue and is deduped by txid like a sale (use for an upsell). With counts_revenue false, it is counted at most once per click and any payout is ignored (use for a cart or a lead).',
539
+ inputSchema: {
540
+ type: 'object',
541
+ properties: {
542
+ label: { type: 'string', description: 'Display name, e.g. "Add to cart".' },
543
+ slug: { type: 'string', description: 'Optional. The event= value, lower case letters, numbers, dashes and underscores, e.g. "add_to_cart". Defaults to the label, slugified.' },
544
+ counts_revenue: { type: 'boolean', description: 'Whether this event adds its payout to revenue. Default false.' },
545
+ },
546
+ required: ['label'],
547
+ },
548
+ },
549
+ {
550
+ name: 'update_custom_event',
551
+ description: 'Rename a custom postback event or change whether it counts revenue. The slug cannot be changed.',
552
+ inputSchema: {
553
+ type: 'object',
554
+ properties: {
555
+ custom_event_id: { type: 'number' },
556
+ label: { type: 'string' },
557
+ counts_revenue: { type: 'boolean' },
558
+ },
559
+ required: ['custom_event_id'],
560
+ },
561
+ },
562
+ {
563
+ name: 'delete_custom_event',
564
+ description: 'Delete a custom postback event. Recorded events stay and their revenue stays in totals, but postbacks using its slug get a 400 from then on. Adding the same slug back reconnects the history.',
565
+ inputSchema: { type: 'object', properties: { custom_event_id: { type: 'number' } }, required: ['custom_event_id'] },
566
+ },
567
+
568
+ // ─── Report views ───────────────────────────────────────────────────────
569
+ {
570
+ name: 'list_report_views',
571
+ description:
572
+ 'List the account\'s saved report views. A view is a name plus the report columns in the order the user wants them (Voluum-style), and `active` is the view every campaign report and the dashboard Overview open on: "fewer" (the short table), "all" (every column), or a view id as a string. Column ids: lander_views, unique_lander_views, visits, unique_visits, clicks, unique_clicks, ctr, unique_ctr, conversions (Sales), cr, refunds, chargebacks, epc, epv, unique_epv, aov, revenue (Net Rev.), plus custom:{slug} for a custom event\'s count and take:{slug} / unique_take:{slug} for the take rate of an event that counts revenue (see list_custom_events). Views only change which columns show; they never change the numbers.',
573
+ inputSchema: { type: 'object', properties: {} },
574
+ },
575
+ {
576
+ name: 'create_report_view',
577
+ description: 'Save a report view: a name and the columns, in order (left to right). By default it becomes the active view. Names are unique per account, case-insensitive (409 name_taken). Up to 50 views.',
578
+ inputSchema: {
579
+ type: 'object',
580
+ properties: {
581
+ name: { type: 'string', description: 'e.g. "Money first".' },
582
+ columns: { type: 'array', items: { type: 'string' }, description: 'Column ids in display order, e.g. ["revenue","conversions","epc","clicks"]. See list_report_views for the ids.' },
583
+ activate: { type: 'boolean', description: 'Make it the active view. Default true.' },
584
+ },
585
+ required: ['name', 'columns'],
586
+ },
587
+ },
588
+ {
589
+ name: 'update_report_view',
590
+ description: 'Rename a saved report view or replace its columns. `columns` replaces the whole list, in order.',
591
+ inputSchema: {
592
+ type: 'object',
593
+ properties: {
594
+ report_view_id: { type: 'number' },
595
+ name: { type: 'string' },
596
+ columns: { type: 'array', items: { type: 'string' } },
597
+ },
598
+ required: ['report_view_id'],
599
+ },
600
+ },
601
+ {
602
+ name: 'delete_report_view',
603
+ description: 'Delete a saved report view. If it was active, reports go back to "fewer".',
604
+ inputSchema: { type: 'object', properties: { report_view_id: { type: 'number' } }, required: ['report_view_id'] },
605
+ },
606
+ {
607
+ name: 'set_active_report_view',
608
+ description: 'Pick the view reports open on: "fewer", "all", or a saved view\'s id.',
609
+ inputSchema: {
610
+ type: 'object',
611
+ properties: { view: { type: 'string', description: '"fewer", "all", or a view id such as "12".' } },
612
+ required: ['view'],
613
+ },
614
+ },
615
+
345
616
  // ─── Traffic sources ────────────────────────────────────────────────────
346
617
  {
347
618
  name: 'list_traffic_sources',
348
619
  description:
349
- 'List the user\'s traffic sources (the platforms they buy traffic from — Facebook, TikTok, Google, …). Each source has a `name`, an optional `platform` (pixel-platform key), and `token_params`: how the source\'s click macros map onto the Google UTM params the resolver captures (utm_source/medium/campaign/term/content), e.g. Facebook utm_campaign = `{{campaign.id}}`, utm_content = Ad ID `{{ad.id}}`. The response also includes a `presets` catalog of ready-made sources with standard macros. Campaigns reference a source via `traffic_source_id` (see create_campaign / update_campaign).',
350
- inputSchema: { type: 'object', properties: {} },
620
+ 'List the user\'s traffic sources (the platforms they buy traffic from: Facebook, TikTok, Google, …). Each source has a `name`, an optional `platform` (pixel-platform key), and `token_params`: how the source\'s click macros map onto the Google UTM params the resolver captures (utm_source/medium/campaign/term/content), e.g. Facebook utm_campaign = `{{campaign.id}}`, utm_content = Ad ID `{{ad.id}}`. The response also includes a `presets` catalog of ready-made sources with standard macros. Campaigns reference a source via `traffic_source_id` (see create_campaign / update_campaign).',
621
+ inputSchema: { type: 'object', properties: { include_archived: { type: 'boolean', description: 'Also list archived ones. Default false.' } } },
351
622
  },
352
623
  {
353
624
  name: 'create_traffic_source',
@@ -399,13 +670,13 @@ const TOOLS = [
399
670
  {
400
671
  name: 'list_tracking_pixels',
401
672
  description:
402
- 'List the user\'s tracking pixels (Facebook, Google Ads, TikTok, etc). Each pixel has a platform, pixel_id, a default_event it fires, an active flag, and has_capi_token (whether a server-side Conversions API token is set — the token itself is never returned). The response also includes a `platforms` catalog with each platform\'s valid `events`, its `defaultEvent`, and `capi` (whether server-side conversions are supported). Pixels fire on short-link clicks (account-wide by default, or per-link selection) and can be chosen as a campaign\'s conversion pixel.',
673
+ 'List the user\'s tracking pixels (Facebook, Google Ads, TikTok, etc). Each pixel has a platform, pixel_id, a default_event it fires, an active flag, and has_capi_token (whether a server-side Conversions API token is set; the token itself is never returned). The response also includes a `platforms` catalog with each platform\'s valid `events`, its `defaultEvent`, and `capi` (whether server-side conversions are supported). Pixels fire on short-link clicks (account-wide by default, or per-link selection) and can be chosen as a campaign\'s conversion pixel. Each pixel also reports test_event_code (test mode when set), last_sent_at / last_sent_event (the last server event the platform accepted) and last_error / last_error_at (the last refusal).',
403
674
  inputSchema: { type: 'object', properties: {} },
404
675
  },
405
676
  {
406
677
  name: 'create_tracking_pixel',
407
678
  description:
408
- 'Create a tracking pixel. `platform` is one of: facebook, google_ads, google_analytics, google_tag_manager, linkedin, twitter, tiktok, pinterest, snapchat, reddit, quora, bing, adroll. `default_event` must be a valid event for that platform (see the `platforms` catalog from list_tracking_pixels) — defaults to the platform default (e.g. Facebook "PageView"). For Facebook and TikTok you can pass a `capi_token` (server-side Conversions API access token) so campaign conversions fire server-to-server.',
679
+ 'Create a tracking pixel. `platform` is one of: facebook, google_ads, google_analytics, google_tag_manager, linkedin, twitter, tiktok, pinterest, snapchat, reddit, quora, bing, adroll. `default_event` must be a valid event for that platform (see the `platforms` catalog from list_tracking_pixels) and defaults to the platform default (e.g. Facebook "PageView"). For Facebook and TikTok you can pass a `capi_token` (server-side Conversions API access token) so campaign conversions fire server-to-server.',
409
680
  inputSchema: {
410
681
  type: 'object',
411
682
  properties: {
@@ -414,6 +685,7 @@ const TOOLS = [
414
685
  label: { type: 'string', description: 'Optional friendly name.' },
415
686
  default_event: { type: 'string', description: 'Event this pixel fires by default (must be valid for the platform).' },
416
687
  capi_token: { type: 'string', description: 'Optional server-side Conversions API access token (Facebook/TikTok only).' },
688
+ test_event_code: { type: 'string', description: 'Optional Meta test event code (Events Manager, Test events). While set, every server event this pixel sends lands in Test events and never counts.' },
417
689
  active: { type: 'boolean', description: 'Whether the pixel is active. Defaults to true.' },
418
690
  },
419
691
  required: ['platform', 'pixel_id'],
@@ -421,7 +693,7 @@ const TOOLS = [
421
693
  },
422
694
  {
423
695
  name: 'update_tracking_pixel',
424
- description: 'Update a tracking pixel\'s pixel_id, label, default_event, active flag, or CAPI token. Pass capi_token as an empty string or null to clear the stored token.',
696
+ description: 'Update a tracking pixel\'s pixel_id, label, default_event, active flag, CAPI token or test event code. Pass capi_token as an empty string or null to clear the stored token.',
425
697
  inputSchema: {
426
698
  type: 'object',
427
699
  properties: {
@@ -430,11 +702,24 @@ const TOOLS = [
430
702
  label: { type: 'string' },
431
703
  default_event: { type: 'string', description: 'New default event (must be valid for the pixel\'s platform).' },
432
704
  capi_token: { type: ['string', 'null'], description: 'New Conversions API token, or empty/null to clear.' },
705
+ test_event_code: { type: ['string', 'null'], description: 'Meta test event code, or empty/null to clear it and go live.' },
433
706
  active: { type: 'boolean' },
434
707
  },
435
708
  required: ['pixel_id_row'],
436
709
  },
437
710
  },
711
+ {
712
+ name: 'test_tracking_pixel',
713
+ description: 'Send one test event (a $1 Purchase by default) server-side with the pixel\'s own token, to prove the pixel id and token work. Only runs when the pixel has a test_event_code, so the test lands in Meta\'s Test events and never counts. Returns ok, and the platform\'s error when it refused. The pixel\'s last_sent_at / last_error (list_tracking_pixels) record the result.',
714
+ inputSchema: {
715
+ type: 'object',
716
+ properties: {
717
+ pixel_id_row: { type: 'number', description: 'The id of the tracking_pixels row (from list_tracking_pixels).' },
718
+ event: { type: 'string', description: 'Optional event name, valid for the platform. Defaults to Purchase.' },
719
+ },
720
+ required: ['pixel_id_row'],
721
+ },
722
+ },
438
723
  {
439
724
  name: 'delete_tracking_pixel',
440
725
  description: 'Delete a tracking pixel. It is removed from any per-link selections and any campaign that used it as its conversion pixel (the campaign\'s conversion_pixel_id is cleared).',
@@ -445,19 +730,19 @@ const TOOLS = [
445
730
  {
446
731
  name: 'list_campaigns',
447
732
  description:
448
- 'List the user\'s campaigns with quick-look stats (visits / offer_clicks / conversions / revenue). Each campaign has a short_url to paste into traffic sources, a postback_url for the affiliate network, and a pixel_snippet to install on landers.',
449
- inputSchema: { type: 'object', properties: {} },
733
+ 'List the user\'s campaigns with quick-look stats (visits / offer_clicks / conversions / revenue). Each campaign has a short_url to paste into traffic sources, and a pixel_snippet to install on landers.',
734
+ inputSchema: { type: 'object', properties: { include_archived: { type: 'boolean', description: 'Also list archived ones. Default false.' } } },
450
735
  },
451
736
  {
452
737
  name: 'create_campaign',
453
738
  description:
454
- 'Create a campaign. A campaign mints one short link (campaign.code) that the user pastes into Facebook / TikTok / etc. The resolver mints a click_id per visit, optionally routes through a lander (weighted), then redirects to an offer (weighted). The response also includes a `click_url` (`…/click`) — the single, campaign-agnostic DEFINED lander click URL the user pastes into their lander\'s CTA button. It\'s the same on every campaign, so one lander can be reused across many; the campaign is resolved at click time from the visitor\'s `bgl_clk_{code}` cookie (short-link traffic) or the ad\'s `?utm_campaign={direct_slug}` that the lander forwards onto the CTA (direct-link). It always resolves to an offer (chosen by the flow graph), never a lander, never a dead page. For DIRECT-LINK campaigns (ad points straight at the lander, no tracker hop), set `direct_slug`, put `?utm_campaign={direct_slug}` on the ad URL, and have the lander forward that utm_campaign onto the CTA — `/click` resolves the campaign from it and the property tag stitches the page view too. `fallback_url` guarantees the click never dead-ends. Conversions come back via the campaign\'s postback URL or the affiliate network\'s postback URL. You can optionally attach offer_ids / lander_ids inline with matching offer_weights / lander_weights arrays for a one-call setup.',
739
+ 'Create a campaign. A campaign mints one short link (campaign.code) that the user pastes into Facebook / TikTok / etc. The resolver mints a click_id per visit, optionally routes through a lander (weighted), then redirects to an offer (weighted). The response also includes a `click_url` (`…/click`): the single, campaign-agnostic DEFINED lander click URL the user pastes into their lander\'s CTA button. It\'s the same on every campaign, so one lander can be reused across many; the campaign is resolved at click time from the visitor\'s `bgl_clk_{code}` cookie (short-link traffic) or the ad\'s `?utm_campaign={direct_slug}` that the lander forwards onto the CTA (direct-link). It always resolves to an offer (chosen by the flow graph), never a lander, never a dead page. For DIRECT-LINK campaigns (ad points straight at the lander, no tracker hop), set `direct_slug`, put `?utm_campaign={direct_slug}` on the ad URL, and have the lander forward that utm_campaign onto the CTA. `/click` resolves the campaign from it and the property tag stitches the page view too. `fallback_url` guarantees the click never dead-ends. Conversions come back on the account\'s one postback URL (get_universal_postback_url), which credits each sale to the campaign its click came from. You can optionally attach offer_ids / lander_ids inline with matching offer_weights / lander_weights arrays for a one-call setup.',
455
740
  inputSchema: {
456
741
  type: 'object',
457
742
  properties: {
458
743
  name: { type: 'string' },
459
744
  traffic_source_id: { type: ['number', 'null'], description: 'Id of one of the user\'s saved traffic sources (from list_traffic_sources) to attribute this campaign to. Preferred over the legacy `traffic_source` string. Mirrors the source name into the display label.' },
460
- traffic_source: { type: 'string', description: 'Legacy structured traffic source key — one of: facebook, instagram, google, youtube, tiktok, microsoft, linkedin, twitter, pinterest, snapchat, reddit, native, email, organic, other. Prefer `traffic_source_id`.' },
745
+ traffic_source: { type: 'string', description: 'Legacy structured traffic source key, one of: facebook, instagram, google, youtube, tiktok, microsoft, linkedin, twitter, pinterest, snapchat, reddit, native, email, organic, other. Prefer `traffic_source_id`.' },
461
746
  traffic_source_label: { type: 'string', description: 'Free text display label.' },
462
747
  country: { type: 'string', description: 'ISO2 default geo.' },
463
748
  cost_model: { type: 'string', enum: ['not_tracked', 'cpc', 'cpm'] },
@@ -465,14 +750,19 @@ const TOOLS = [
465
750
  transition_mode: { type: 'string', enum: ['302', 'meta_refresh'] },
466
751
  domain_id: { type: ['number', 'null'], description: 'Optional id of one of the user\'s active custom domains (from list_domains). The campaign\'s short link will resolve at that hostname. If omitted, defaults to the user\'s first active custom domain, or buygolinks.com if they have none. Pass null to force the platform default.' },
467
752
  conversion_pixel_id: { type: ['number', 'null'], description: 'Optional id of one of the user\'s tracking pixels (from list_tracking_pixels) to fire when this campaign converts (server-side via the pixel\'s CAPI token when set). Pass null for no conversion pixel.' },
468
- conversion_event: { type: 'string', description: 'Event name to fire on conversion (e.g. "Purchase", "Lead", "CompletePayment"). Must be a valid event for the conversion pixel\'s platform; defaults to that pixel\'s default_event.' },
753
+ conversion_event: { type: 'string', description: 'Event name to fire on conversion (e.g. "Purchase", "Lead", "CompletePayment"). Must be a valid event for the conversion pixel\'s platform; defaults to that pixel\'s default_event. Prefer pixel_events, which also covers upsells, checkouts and carts.' },
754
+ pixel_events: {
755
+ type: ['object', 'null'],
756
+ description: 'Which of this campaign\'s events reach its conversion pixel, and as what. Keys are "conversion" (a sale) or one of the account\'s custom event slugs (list_custom_events), values {event, first_only?}. `event` must be valid for the pixel\'s platform (e.g. Facebook "Purchase", "InitiateCheckout", "AddToCart"); an empty event means "don\'t send". first_only: true sends only the click\'s first event of that kind (e.g. the first sale, not rebills). A missing key is not sent. Example: {"conversion":{"event":"Purchase","first_only":true},"upsell":{"event":"Purchase"},"initiate_checkout":{"event":"InitiateCheckout"}}. Null keeps the old rule (sales only, as conversion_event). Events are sent server-side the moment their postback arrives; refunds and re-fired orders never are. Add &email= to the postback URL so the platform can match the buyer (hashed, never stored).',
757
+ },
469
758
  report_value_mode: { type: 'string', enum: ['payout', 'fixed', 'adjusted'], description: 'What revenue VALUE to report back to the ad platform on a CAPI conversion. "payout" (default) = the real conversion payout; "fixed" = always report report_value_fixed (e.g. 1); "adjusted" = real payout minus report_value_adjust_pct % then minus report_value_adjust_flat (so the user\'s margin/cost is withheld from what the platform optimizes against). Floored at 0.' },
470
759
  report_value_fixed: { type: ['number', 'null'], description: 'Used when report_value_mode = "fixed": the exact value (USD) reported on every conversion regardless of real payout.' },
471
760
  report_value_adjust_pct: { type: ['number', 'null'], description: 'Used when report_value_mode = "adjusted": percentage (0–100) subtracted from the real payout first (e.g. 10 reports payout − 10%).' },
472
761
  report_value_adjust_flat: { type: ['number', 'null'], description: 'Used when report_value_mode = "adjusted": flat amount (USD) subtracted after the percentage.' },
473
- direct_slug: { type: ['string', 'null'], description: 'DEPRECATED single-tag form (still accepted) — prefer direct_slugs. Optional direct-link tag, unique per user (lowercased alnum + dashes, e.g. "bsu-fb-cold"). Returns 409 (direct_slug_taken) if another campaign already uses it. Pass null to clear.' },
474
- direct_slugs: { type: 'array', description: 'Direct-link tags this campaign claims, for running traffic STRAIGHT to a lander with no tracker hop. A campaign can claim MANY utm_campaign values (an older campaign being migrated in, a lander turned into a campaign, etc.) — each routes through this campaign\'s flow. Put `?utm_campaign={slug}` on the ad URL; the lander forwards it onto the /click CTA and the property tag stitches the page view. Each item is either a string (used as both slug + label) or `{ slug, label }` where slug is sanitized (lowercased alnum+dashes, what gets matched) and label is the friendly display value (e.g. "ABO Testing Campaign"). Each slug is unique per user → 409 (direct_slug_taken) on collision. Pass [] to clear all tags.', items: { oneOf: [ { type: 'string' }, { type: 'object', properties: { slug: { type: 'string' }, label: { type: 'string' } }, required: ['slug'] } ] } },
475
- fallback_url: { type: ['string', 'null'], description: 'Optional URL the click falls back to when the flow yields no live offer — the never-dead-end guarantee. Pass null to use the platform default.' },
762
+ direct_slug: { type: ['string', 'null'], description: 'DEPRECATED single-tag form (still accepted). Prefer direct_slugs. Optional direct-link tag, unique per user (lowercased alnum + dashes, e.g. "bsu-fb-cold"). Returns 409 (direct_slug_taken) if another campaign already uses it. Pass null to clear.' },
763
+ direct_slugs: { type: 'array', description: 'Direct-link tags this campaign claims, for running traffic STRAIGHT to a lander with no tracker hop. A campaign can claim MANY utm_campaign values (an older campaign being migrated in, a lander turned into a campaign, etc.). Each routes through this campaign\'s flow. Put `?utm_campaign={slug}` on the ad URL; the lander forwards it onto the /click CTA and the property tag stitches the page view. Each item is either a string (used as both slug + label) or `{ slug, label }` where slug is sanitized (lowercased alnum+dashes, what gets matched) and label is the friendly display value (e.g. "ABO Testing Campaign"). Each slug is unique per user → 409 (direct_slug_taken) on collision. Pass [] to clear all tags.', items: { oneOf: [ { type: 'string' }, { type: 'object', properties: { slug: { type: 'string' }, label: { type: 'string' } }, required: ['slug'] } ] } },
764
+ fallback_url: { type: ['string', 'null'], description: 'Optional URL the click falls back to when the flow yields no live offer: the never-dead-end guarantee. Pass null to use the platform default.' },
765
+ open_in_browser: { type: 'boolean', description: 'On a phone, send the lander-to-offer tap out of Facebook\'s, Instagram\'s or TikTok\'s own in-app browser to the phone\'s real browser (Safari or Chrome) before the offer loads. Default false. Trade-off: the visitor leaves the browser holding Meta\'s cookies, so a Meta pixel on the offer page may match fewer sales (the fbclid still rides on the URL). Offers that belong to an app (Amazon and the other apps short links open) always open that app on a phone, leaving the in-app browser first when they need to, whatever this says.' },
476
766
  offer_ids: { type: 'array', items: { type: 'number' } },
477
767
  offer_weights: { type: 'array', items: { type: 'number' } },
478
768
  lander_ids: { type: 'array', items: { type: 'number' } },
@@ -483,7 +773,7 @@ const TOOLS = [
483
773
  },
484
774
  {
485
775
  name: 'get_campaign',
486
- description: 'Get a single campaign with its attached offers, landers, postback URL, short URL, and pixel snippet.',
776
+ description: 'Get a single campaign with its attached offers, landers, short URL, and pixel snippet. `direct_lander` is true on a direct link campaign: its ads link straight to a lander, so `short_url` goes on that page\'s button and not in the ads.',
487
777
  inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
488
778
  },
489
779
  {
@@ -503,14 +793,20 @@ const TOOLS = [
503
793
  transition_mode: { type: 'string', enum: ['302', 'meta_refresh'] },
504
794
  domain_id: { type: ['number', 'null'], description: 'Id of one of the user\'s active custom domains, or null to move the campaign back to buygolinks.com.' },
505
795
  conversion_pixel_id: { type: ['number', 'null'], description: 'Id of a tracking pixel to fire on conversion, or null to clear.' },
506
- conversion_event: { type: 'string', description: 'Event the conversion pixel fires (must be valid for that pixel\'s platform).' },
796
+ conversion_event: { type: 'string', description: 'Event a sale is sent as (must be valid for that pixel\'s platform). Moves the sale\'s pixel_events rule with it. Prefer pixel_events.' },
797
+ pixel_events: {
798
+ type: ['object', 'null'],
799
+ description: 'Which of this campaign\'s events reach its conversion pixel, and as what. Keys are "conversion" (a sale) or one of the account\'s custom event slugs (list_custom_events), values {event, first_only?}. `event` must be valid for the pixel\'s platform (e.g. Facebook "Purchase", "InitiateCheckout", "AddToCart"); an empty event means "don\'t send". first_only: true sends only the click\'s first event of that kind (e.g. the first sale, not rebills). A missing key is not sent. Example: {"conversion":{"event":"Purchase","first_only":true},"upsell":{"event":"Purchase"},"initiate_checkout":{"event":"InitiateCheckout"}}. Null keeps the old rule (sales only, as conversion_event). Events are sent server-side the moment their postback arrives; refunds and re-fired orders never are. Add &email= to the postback URL so the platform can match the buyer (hashed, never stored).',
800
+ },
507
801
  report_value_mode: { type: 'string', enum: ['payout', 'fixed', 'adjusted'], description: 'What revenue value to report back to the ad platform on conversion: "payout" (real), "fixed" (report_value_fixed), or "adjusted" (real payout − report_value_adjust_pct% − report_value_adjust_flat, floored at 0).' },
508
802
  report_value_fixed: { type: ['number', 'null'], description: 'Fixed value (USD) reported on every conversion when report_value_mode = "fixed". Null to clear.' },
509
803
  report_value_adjust_pct: { type: ['number', 'null'], description: 'Percentage (0–100) subtracted from real payout when report_value_mode = "adjusted". Null to clear.' },
510
804
  report_value_adjust_flat: { type: ['number', 'null'], description: 'Flat USD amount subtracted (after the %) when report_value_mode = "adjusted". Null to clear.' },
511
- direct_slug: { type: ['string', 'null'], description: 'DEPRECATED single-tag form (still accepted) — prefer direct_slugs. Direct-link tag for utm_campaign-based stitching (unique per user), or null to clear. Returns 409 (direct_slug_taken) on collision.' },
805
+ direct_slug: { type: ['string', 'null'], description: 'DEPRECATED single-tag form (still accepted). Prefer direct_slugs. Direct-link tag for utm_campaign-based stitching (unique per user), or null to clear. Returns 409 (direct_slug_taken) on collision.' },
512
806
  direct_slugs: { type: 'array', description: 'Replace this campaign\'s direct-link tags. A campaign can claim MANY utm_campaign values, each routing through its flow. Each item is a string (slug+label) or `{ slug, label }`. Slugs are unique per user → 409 (direct_slug_taken) on collision. Pass [] to clear all.', items: { oneOf: [ { type: 'string' }, { type: 'object', properties: { slug: { type: 'string' }, label: { type: 'string' } }, required: ['slug'] } ] } },
513
807
  fallback_url: { type: ['string', 'null'], description: 'Never-dead-end fallback URL when no live offer exists, or null to use the platform default.' },
808
+ open_in_browser: { type: 'boolean', description: 'On a phone, send the lander-to-offer tap out of Facebook\'s, Instagram\'s or TikTok\'s own in-app browser to the phone\'s real browser (Safari or Chrome) before the offer loads. Default false. Trade-off: the visitor leaves the browser holding Meta\'s cookies, so a Meta pixel on the offer page may match fewer sales (the fbclid still rides on the URL). Offers that belong to an app (Amazon and the other apps short links open) always open that app on a phone, leaving the in-app browser first when they need to, whatever this says.' },
809
+ dim_labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'The user\'s names for this campaign\'s tokens, keyed by dimension: { "utm_medium": "Ad Name", "param:adset_name": "Ad Set" }. Replaces the whole set, so send the names to keep too. Only utm_* and param:<name> keys are kept, and a blank name removes that one. The report keeps showing the URL\'s name beside it.' },
514
810
  archived: { type: 'boolean' },
515
811
  },
516
812
  required: ['campaign_id'],
@@ -523,7 +819,7 @@ const TOOLS = [
523
819
  },
524
820
  {
525
821
  name: 'duplicate_campaign',
526
- description: 'Deep-copy a campaign: clones the campaign settings plus its full routing flow (source → landers → offers graph and split-test weights) and its offer/lander pools. The copy gets a fresh short link + postback token and a name like "COPY - {original}" (or "COPY (1) - {original}", "COPY (2) - …" when copying a copy). Stats are NOT copied; the direct-link slug is left blank (it must be unique). Returns the new campaign, including its short_url/click_url. Useful for spinning up a new geo/angle variant of a working campaign without rebuilding the flow.',
822
+ description: 'Deep-copy a campaign: clones the campaign settings plus its full routing flow (source → landers → offers graph and split-test weights) and its offer/lander pools. The copy gets a fresh short link and a name like "COPY - {original}" (or "COPY (1) - {original}", "COPY (2) - …" when copying a copy). Stats are NOT copied; the direct-link slug is left blank (it must be unique). Returns the new campaign, including its short_url/click_url. Useful for spinning up a new geo/angle variant of a working campaign without rebuilding the flow.',
527
823
  inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
528
824
  },
529
825
  {
@@ -567,7 +863,7 @@ const TOOLS = [
567
863
  {
568
864
  name: 'get_campaign_flow',
569
865
  description:
570
- 'Get the campaign\'s routing flow graph (n8n-style). Returns { flow: { nodes, edges }, offers, landers }. Nodes are { id, kind: "source"|"lander"|"offer", ref_id, pos_x, pos_y, offer?, lander? } — exactly one "source" node (the traffic entry). Edges are { id, from_node_id, to_node_id, weight } and only go source→lander, source→offer (direct), or lander→offer; weight is the split-test weight. This graph is what the resolver walks per click, letting a specific lander route to its own specific offers.',
866
+ 'Get the campaign\'s routing flow graph (n8n-style). Returns { flow: { nodes, edges }, offers, landers }. Nodes are { id, kind: "source"|"lander"|"offer", ref_id, pos_x, pos_y, direct, offer?, lander? }, with exactly one "source" node (the traffic entry). Edges are { id, from_node_id, to_node_id, weight } and only go source→lander, source→offer (direct), or lander→offer; weight is the split-test weight. This graph is what the resolver walks per click, letting a specific lander route to its own specific offers.',
571
867
  inputSchema: {
572
868
  type: 'object',
573
869
  properties: { campaign_id: { type: 'number' } },
@@ -577,7 +873,7 @@ const TOOLS = [
577
873
  {
578
874
  name: 'set_campaign_flow',
579
875
  description:
580
- 'Replace the campaign\'s routing flow graph. Body: { nodes: [{ tmp_id, kind, ref_id, pos_x, pos_y }], edges: [{ from, to, weight }] }. tmp_id is any client-stable string key; edges reference nodes by tmp_id. Provide exactly one node with kind="source" (ref_id null) as the traffic entry; "lander" / "offer" nodes set ref_id to a lander/offer id you own. Edges must go source→lander, source→offer, or lander→offer. weight is an integer split-test weight (higher = more traffic). Saving also re-derives the flat offer/lander pools, so legacy reports stay consistent. Use this to add a second offer for a split test, divert a lander to a different offer, or insert a landing page before an offer.',
876
+ 'Replace the campaign\'s routing flow graph. Body: { nodes: [{ tmp_id, kind, ref_id, pos_x, pos_y, direct? }], edges: [{ from, to, weight }] }. Set direct=true on a lander node when the ad links straight to that page and the campaign short link or `/click` sits on its button: the link then goes on to that lander\'s offers instead of sending the visitor to the page, and direct landers win over every other edge on the source. No ad URL has to change. tmp_id is any client-stable string key; edges reference nodes by tmp_id. Provide exactly one node with kind="source" (ref_id null) as the traffic entry; "lander" / "offer" nodes set ref_id to a lander/offer id you own. Edges must go source→lander, source→offer, or lander→offer. weight is an integer split-test weight (higher = more traffic). Saving also re-derives the flat offer/lander pools, so legacy reports stay consistent. Use this to add a second offer for a split test, divert a lander to a different offer, or insert a landing page before an offer.',
581
877
  inputSchema: {
582
878
  type: 'object',
583
879
  properties: {
@@ -592,6 +888,7 @@ const TOOLS = [
592
888
  ref_id: { type: ['number', 'null'] },
593
889
  pos_x: { type: 'number' },
594
890
  pos_y: { type: 'number' },
891
+ direct: { type: 'boolean', description: 'Landers only. True when the ad links straight to this page, so the campaign link goes on to its offers instead of back to the page.' },
595
892
  },
596
893
  required: ['tmp_id', 'kind'],
597
894
  },
@@ -615,15 +912,20 @@ const TOOLS = [
615
912
  {
616
913
  name: 'get_campaign_stats',
617
914
  description:
618
- 'Get a campaign\'s full report: totals (visits / unique / lander_views / offer_clicks / conversions / revenue / cost / profit / CTR / CR / EPC / ROI), by_day / by_country / by_device / by_browser / by_referrer / by_hour, per-offer and per-lander performance, the 50 most recent events, and `breakdowns` — Voluum-style grouped report rows for 9 dimensions (utm_campaign / utm_source / utm_content / utm_term / lander / offer / country / device / day), each row {key, visits, clicks, conversions, revenue, ctr, cr, epc}. Offer clicks and conversions are attributed to the utm values of the visit that minted their click_id (postback conversions landing after the visit included); the `offer` dimension attributes each visit to the offer its click routed to (keyed by offer NAME), so split-test rows read correctly; the `lander` dimension is the page the click came FROM (visit referrer normalized to host+path — full paths for landers running the property tag, ad-platform hosts like m.facebook.com for straight-from-ad clicks); traffic with no value groups under "(none)". DRILL-DOWN: pass any dimension as a filter (e.g. utm_campaign="Summer Sale", lander="promo.example.com/lander-a", or offer="My Offer") and the WHOLE report — totals, by_day, breakdowns — narrows to that traffic slice; combine filters to drill deeper (e.g. utm_campaign + group the result\'s `breakdowns.offer` to see which offer converts for that ad). Pass "(none)" to match events with no value.',
915
+ 'Get a campaign\'s full report: totals (visits / unique_visits / lander_views / unique_lander_views / offer_clicks / unique_offer_clicks / conversions / refunds / chargebacks / revenue / gross_revenue / reversed_revenue / custom / cost / profit / CTR / CR / EPC / AOV / ROI). `conversions` counts sales. `aov` is average order value: net revenue per sale (0 with no sale). `revenue` is NET: sales and revenue-counting custom events, minus refunds and chargebacks. `custom` is keyed by event slug, each {count, revenue}, and `custom_events` lists the account\'s definitions with their labels. The rest of the report: by_day / by_country / by_device / by_browser / by_referrer / by_hour, per-offer and per-lander performance, the 50 most recent events, and `breakdowns`: Voluum-style grouped report rows for 10 dimensions (utm_campaign / utm_medium / utm_source / utm_content / utm_term / lander / offer / country / device / day), each row {key, visits, unique_visits, clicks, unique_clicks, lander_views, unique_lander_views, conversions, refunds, chargebacks, revenue, gross_revenue, reversed_revenue, custom, ctr, cr, epc, aov}. UNIQUES count people, not rows: unique_visits and unique_clicks (unique_offer_clicks in totals) go by the IP and user agent of the visit that minted the click, unique_lander_views by the property tag\'s visitor cookie, then IP and device. A person who taps the button three times is 3 clicks and 1 unique click. `totals.unique_events` counts the people behind each sale and custom event, keyed `conversion` or the event slug (refunds and chargebacks are left out). Offer clicks and conversions are attributed to the utm values of the visit that minted their click_id (postback conversions landing after the visit included); the `offer` dimension attributes each visit to the offer its click routed to (keyed by offer NAME), so split-test rows read correctly; the `lander` dimension is the page the click came FROM (visit referrer normalized to host+path; full paths for landers running the property tag, ad-platform hosts like m.facebook.com for straight-from-ad clicks); traffic with no value groups under "(none)". DRILL-DOWN: pass any dimension as a filter (e.g. utm_campaign="Summer Sale", lander="promo.example.com/lander-a", or offer="My Offer") and the WHOLE report (totals, by_day, breakdowns) narrows to that traffic slice; combine filters to drill deeper (e.g. utm_campaign + group the result\'s `breakdowns.offer` to see which offer converts for that ad). Pass "(none)" to match events with no value. DIRECT LANDERS: when the flow has a direct lander, `direct_lander` is true, `totals.lander_views` and each row\'s `lander_views` count that page\'s property-tag page views, and `lander_ctr` is visits per lander view. Page views have no offer, so `lander_views` is 0 on the offer dimension and under an offer filter. A campaign with direct-link tags counts tagged traffic only. STACKED REPORT: pass `group` (up to 3 dimensions, e.g. ["utm_campaign", "utm_medium", "offer"]) and `report.rows` comes back as a Binom-style tree, each row carrying its slice\'s metrics and `children` for the next level. With no `group` the report groups by offer. `group` also accepts region / city / browser / language / weekday / hour (day, weekday and hour count in the account time zone, see `timezone` on the response) and custom tokens as `param:<name>`: every extra param on the inbound link (adset_name=..., sub3=..., utm_id=...) is kept on the visit, except click ids and personal fields. `passed_dims` lists what the URLs really carried in the window: the utm_* tokens that had a value, then the `param:<name>` ones. On a direct lander the ad\'s URL counts even if the page\'s button drops it: the visit fills in from that visitor\'s page view, and `lander_views` split by token too. `dim_labels` holds the names the user gave this campaign\'s tokens (e.g. utm_medium: "Ad Name"), set with update_campaign. Read those before describing a dimension to the user, and never assume what a slot holds: setups differ even between two campaigns on one offer, so with no name on a token, call it by its URL name.',
619
916
  inputSchema: {
620
917
  type: 'object',
621
918
  properties: {
622
919
  campaign_id: { type: 'number' },
623
920
  days: { type: 'number', description: 'Rolling window size in days (1–90). Defaults to 14. Ignored when `from` is set.' },
624
- from: { type: 'string', description: 'ISO date/datetime — start of an explicit closed range (overrides days).' },
625
- to: { type: 'string', description: 'ISO date/datetime — end of the explicit range. Defaults to now when omitted.' },
921
+ tz: { type: 'string', description: 'IANA time zone (e.g. America/New_York) to count days, weekdays and hours in. Defaults to the account time zone (set_account_timezone), else UTC. The response echoes it as `timezone`.' },
922
+ group: { type: 'array', items: { type: 'string' }, maxItems: 3, description: 'Up to 3 dimensions to stack into the `report.rows` tree, outermost first. Custom tokens are written param:<name>.' },
923
+ breakdowns: { type: 'boolean', description: 'Default true. false skips the ten fixed `breakdowns` (returned empty) and keeps everything else, which makes a long window on a busy campaign several times faster. Use `group` for the dimensions you need.' },
924
+ filters: { type: 'object', additionalProperties: { type: 'string' }, description: 'Extra drill-down filters by dimension, for anything without its own argument, e.g. {"param:adset_name": "Broad 35+", "browser": "safari"}.' },
925
+ from: { type: 'string', description: 'ISO date/datetime: start of an explicit closed range (overrides days).' },
926
+ to: { type: 'string', description: 'ISO date/datetime: end of the explicit range. Defaults to now when omitted.' },
626
927
  utm_campaign: { type: 'string', description: 'Drill-down filter: only traffic whose minting visit carried this utm_campaign.' },
928
+ utm_medium: { type: 'string', description: 'Drill-down filter by utm_medium.' },
627
929
  utm_source: { type: 'string', description: 'Drill-down filter by utm_source.' },
628
930
  utm_content: { type: 'string', description: 'Drill-down filter by utm_content.' },
629
931
  utm_term: { type: 'string', description: 'Drill-down filter by utm_term.' },
@@ -631,7 +933,7 @@ const TOOLS = [
631
933
  offer: { type: 'string', description: 'Drill-down filter by offer NAME (as returned in breakdowns.offer keys).' },
632
934
  country: { type: 'string', description: 'Drill-down filter by ISO2 country of the minting visit.' },
633
935
  device: { type: 'string', description: 'Drill-down filter by device of the minting visit.' },
634
- day: { type: 'string', description: 'Drill-down filter by UTC day (YYYY-MM-DD) of the minting visit.' },
936
+ day: { type: 'string', description: 'Drill-down filter by day (YYYY-MM-DD) of the minting visit, in the report time zone.' },
635
937
  },
636
938
  required: ['campaign_id'],
637
939
  },
@@ -642,12 +944,12 @@ const TOOLS = [
642
944
  name: 'list_properties',
643
945
  description:
644
946
  'List the user\'s properties with quick-look stats (page_views / unique_visitors / conversions / revenue). Best one-call tool for "page views per property" / "page views for each site". A property is a whole-site container: the user installs ONE universal tag (returned as `snippet`) on every page of their site and every page auto-reports pageviews, time-on-page, scroll depth, outbound clicks, and conversions. Page events auto-attribute to a campaign when a visitor arrives carrying that campaign\'s click.',
645
- inputSchema: { type: 'object', properties: {} },
947
+ inputSchema: { type: 'object', properties: { include_archived: { type: 'boolean', description: 'Also list archived ones. Default false.' } } },
646
948
  },
647
949
  {
648
950
  name: 'create_property',
649
951
  description:
650
- 'Create a property (a GTM/GA-style site container). Returns the property with its public property_key, the `tracking_domain` it\'s bound to, and an install `snippet` — a single GA/Facebook-style inline <script> block (a self-invoking function that sets up a conversion-queue stub and injects the async tag) the user pastes into the <head> of every page of their site. REQUIRES an active custom domain: the tag is served from the user\'s own domain so it stays first-party to their site, which is required for tracking to work on iOS/Safari (a tag loaded cross-site from buygolinks.com gets its visitor cookie partitioned/blocked). Defaults to the user\'s first active custom domain; pass `domain_id` (from list_domains) to pin a specific one. If the user has no active custom domain, returns error `domain_required` — have them add one via create_domain first. To record conversions, the site sets window.bglConversion = true (optionally window.bglRevenue) on a thank-you page, or calls window.bglFireConversion(revenue) — the queue stub makes that call safe even before the async tag finishes loading.',
952
+ 'Create a property (a GTM/GA-style site container). Returns the property with its public property_key, the `tracking_domain` it\'s bound to, and an install `snippet`: a single GA/Facebook-style inline <script> block (a self-invoking function that sets up a conversion-queue stub and injects the async tag) the user pastes into the <head> of every page of their site. REQUIRES an active custom domain: the tag is served from the user\'s own domain so it stays first-party to their site, which is required for tracking to work on iOS/Safari (a tag loaded cross-site from buygolinks.com gets its visitor cookie partitioned/blocked). Defaults to the user\'s first active custom domain; pass `domain_id` (from list_domains) to pin a specific one. If the user has no active custom domain, returns error `domain_required`. Have them add one via create_domain first. To record conversions, the site sets window.bglConversion = true (optionally window.bglRevenue) on a thank-you page, or calls window.bglFireConversion(revenue). The queue stub makes that call safe even before the async tag finishes loading.',
651
953
  inputSchema: {
652
954
  type: 'object',
653
955
  properties: {
@@ -665,7 +967,7 @@ const TOOLS = [
665
967
  },
666
968
  {
667
969
  name: 'update_property',
668
- description: 'Update a property\'s name, site_url, tracking domain (domain_id), or archive state. Pass `domain_id` to re-bind the install snippet to a different active custom domain — it must be one of the user\'s active custom domains (a property can never fall back to buygolinks.com, since that breaks iOS tracking). Returns the updated property with its `tracking_domain` and regenerated `snippet`.',
970
+ description: 'Update a property\'s name, site_url, tracking domain (domain_id), or archive state. Pass `domain_id` to re-bind the install snippet to a different active custom domain. It must be one of the user\'s active custom domains (a property can never fall back to buygolinks.com, since that breaks iOS tracking). Returns the updated property with its `tracking_domain` and regenerated `snippet`.',
669
971
  inputSchema: {
670
972
  type: 'object',
671
973
  properties: {
@@ -711,6 +1013,7 @@ const TOOLS = [
711
1013
  properties: {
712
1014
  property_id: { type: 'number' },
713
1015
  days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14.' },
1016
+ tz: { type: 'string', description: 'IANA time zone (e.g. America/New_York) to count days, weekdays and hours in. Defaults to the account time zone (set_account_timezone), else UTC. The response echoes it as `timezone`.' },
714
1017
  },
715
1018
  required: ['property_id'],
716
1019
  },
@@ -718,43 +1021,54 @@ const TOOLS = [
718
1021
  {
719
1022
  name: 'list_property_pages',
720
1023
  description:
721
- 'List the distinct pages observed across ALL the user\'s properties (the pageview stream), most-viewed first. Each entry is `{ url, path, title, property_id, property_name, views }`. Any of these pages can be used as a campaign LANDER — pass its `url` to `create_lander` (the page title makes a good lander name) and then wire the resulting lander into a campaign flow via `set_campaign_flow`. Use this to turn a real page on the user\'s site into a lander without retyping its URL.',
1024
+ 'List the distinct pages observed across ALL the user\'s properties (the pageview stream), most-viewed first. Each entry is `{ url, path, title, property_id, property_name, views }`. Any of these pages can be used as a campaign LANDER: pass its `url` to `create_lander` (the page title makes a good lander name) and then wire the resulting lander into a campaign flow via `set_campaign_flow`. Use this to turn a real page on the user\'s site into a lander without retyping its URL.',
722
1025
  inputSchema: { type: 'object', properties: {} },
723
1026
  },
724
1027
  {
725
1028
  name: 'list_observed_campaign_tags',
726
1029
  description:
727
- 'List the distinct `utm_campaign` values that have actually been SEEN arriving on the user\'s properties/visits over the last 90 days, most-active first. Each entry is `{ utm_campaign, slug, events, utm_source, utm_medium, lander_url, claimed_by }` where `utm_campaign` is the raw value (for display, e.g. "ABO Testing Campaign"), `slug` is its sanitized form (what gets stored + matched), and `claimed_by` is `{ id, name }` of the BGL campaign that already owns that slug, or null if unclaimed. Use this to WIRE UP AN ALREADY-RUNNING DIRECT-LINK CAMPAIGN: when an ad on FB/Google points straight at a lander (no tracker hop) with `?utm_campaign={tag}`, that tag shows up here once the universal property tag reports a page view. Pick an unclaimed tag and add it to a campaign\'s `direct_slugs` (via `update_campaign`, passing `{ slug, label: utm_campaign }`) so BGL attributes that traffic — and its `/click` CTA — to the right campaign + offer. A campaign can claim several tags. Skip tags whose `claimed_by` is non-null (another campaign already owns them).',
1030
+ 'List the distinct `utm_campaign` values that have actually been SEEN arriving on the user\'s properties/visits over the last 90 days, most-active first. Each entry is `{ utm_campaign, slug, events, utm_source, utm_medium, lander_url, claimed_by }` where `utm_campaign` is the raw value (for display, e.g. "ABO Testing Campaign"), `slug` is its sanitized form (what gets stored + matched), and `claimed_by` is `{ id, name }` of the BGL campaign that already owns that slug, or null if unclaimed. Use this to WIRE UP AN ALREADY-RUNNING DIRECT-LINK CAMPAIGN: when an ad on FB/Google points straight at a lander (no tracker hop) with `?utm_campaign={tag}`, that tag shows up here once the universal property tag reports a page view. Pick an unclaimed tag and add it to a campaign\'s `direct_slugs` (via `update_campaign`, passing `{ slug, label: utm_campaign }`) so BGL attributes that traffic, and its `/click` CTA, to the right campaign + offer. A campaign can claim several tags. Skip tags whose `claimed_by` is non-null (another campaign already owns them).',
728
1031
  inputSchema: { type: 'object', properties: {} },
729
1032
  },
730
1033
  {
731
1034
  name: 'get_tracker_overview',
732
1035
  description:
733
- 'PAGE VIEWS live here. Get the unified Tracker dashboard rollup across BOTH event streams (site properties + campaigns) in one call. Returns totals (page_views / unique_visitors / visits / offer_clicks / conversions / revenue), a merged daily activity series (by_day), dimension breakdowns (by_country / by_device / by_browser), per-property and per-campaign rollups (the per-property rollup gives page_views for each site/property), a per-UTM-campaign rollup (utm_campaigns — auto-detected Google utm_source/medium/campaign/content tags with events/conversions/revenue), and the 50 most recent events across both streams. This is the go-to tool for "page views", "page views per property", "campaign traffic", and "is everything running and getting traffic?". NOTE: page_views here is Tracker site traffic and is SEPARATE from branded short-link clicks (get_overview_stats). If the user asks for "clicks AND page views" together, call BOTH this tool and get_overview_stats and combine them.',
1036
+ 'PAGE VIEWS live here. Get the unified Tracker dashboard rollup across BOTH event streams (site properties + campaigns) in one call. Returns totals (page_views / unique_visitors / visits / offer_clicks / conversions / revenue), a merged daily activity series (by_day), dimension breakdowns (by_country / by_device / by_browser), per-property and per-campaign rollups (the per-property rollup gives page_views for each site/property; each campaign row has visits, lander_views, offer_clicks, conversions, net revenue and direct_lander, and its EPV is revenue / lander_views when direct_lander is true, else revenue / visits, the same as the campaign Report), a per-UTM-campaign rollup (utm_campaigns: auto-detected Google utm_source/medium/campaign/content tags with events/conversions/revenue), a per-source rollup (utm_sources: one row per utm_source with events/conversions/net revenue, uncapped). In both, a sale is credited to the UTMs of the visit on its click, since a postback carries none, and events counts traffic only. and the 50 most recent events across both streams. This is the go-to tool for "page views", "page views per property", "campaign traffic", and "is everything running and getting traffic?". NOTE: page_views here is Tracker site traffic and is SEPARATE from branded short-link clicks (get_overview_stats). If the user asks for "clicks AND page views" together, call BOTH this tool and get_overview_stats and combine them.',
734
1037
  inputSchema: {
735
1038
  type: 'object',
736
1039
  properties: {
737
- days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14.' },
1040
+ days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14. Ignored when `from` is set.' },
1041
+ from: { type: 'string', description: 'ISO date/datetime: start of an explicit range (overrides days). For "today" pass local midnight; leave `to` out to stay open to now.' },
1042
+ to: { type: 'string', description: 'ISO date/datetime: end of the explicit range. Defaults to now.' },
1043
+ tz: { type: 'string', description: 'IANA time zone (e.g. America/New_York) to count days, weekdays and hours in. Defaults to the account time zone (set_account_timezone), else UTC. The response echoes it as `timezone`.' },
1044
+ offer_id: { type: 'number', description: 'Scope to one offer: its campaign traffic and the sales on its clicks. Drops the property stream.' },
1045
+ property_id: { type: 'number', description: 'Scope to one property\'s page views. Drops the campaign stream.' },
738
1046
  },
739
1047
  },
740
1048
  },
741
1049
  {
742
1050
  name: 'get_tracker_events',
743
1051
  description:
744
- 'Query the unified Tracker event log across BOTH streams (site properties + campaigns), with full search / filter / sort over auto-detected Google UTM tags. Every event carries utm_source / utm_medium / utm_campaign / utm_term / utm_content. Use `q` for free-text search (matches any utm value, country, device, browser, referrer, source name, event type); `stream` to scope to all|property|campaign; `event` to filter by event_type; the utm_* params for exact-match filtering; and `sort`+`dir` to order by ts|revenue|utm_campaign|utm_source|country. Returns the matching page of events plus `facets` (distinct utm values present in the window) to drive filter pickers, and `total` for the full match count.',
1052
+ 'Query the unified Tracker event log across BOTH streams (site properties + campaigns), with full search / filter / sort over auto-detected Google UTM tags. Every event carries utm_source / utm_medium / utm_campaign / utm_term / utm_content. Use `q` for free-text search (matches any utm value, country, device, browser, referrer, source name, event type); `stream` to scope to all|property|campaign; `event` to filter by event_type; the utm_* params for exact-match filtering; `campaign_id` / `offer_id` / `property_id` / `link_id` to scope it; and `sort`+`dir` to order by any column. Rows from a campaign carry the location, IP and UTMs of the visit on their click, so a sale row shows where its buyer came from. Bot traffic (Meta ad reviewers, crawlers) is left out. Returns the matching page of events plus `facets` (distinct utm values present in the window) to drive filter pickers, and `total` for the full match count.',
745
1053
  inputSchema: {
746
1054
  type: 'object',
747
1055
  properties: {
748
- days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14.' },
749
- q: { type: 'string', description: 'Free-text search across utm_*, country, device, browser, referrer, source name, event type.' },
750
- stream: { type: 'string', enum: ['all', 'property', 'campaign'], description: 'Scope to one stream. Defaults to all.' },
1056
+ days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14. Ignored when `from` is set.' },
1057
+ from: { type: 'string', description: 'ISO date/datetime: start of an explicit range (overrides days).' },
1058
+ to: { type: 'string', description: 'ISO date/datetime: end of the explicit range. Defaults to now.' },
1059
+ q: { type: 'string', description: 'Free-text search across utm_*, country, city, device, browser, referrer, source name, event type.' },
1060
+ stream: { type: 'string', enum: ['all', 'property', 'campaign'], description: 'Scope to one stream. Defaults to all. Ignored when a campaign, offer, property or link scope is set.' },
1061
+ campaign_id: { type: 'number', description: 'Only this campaign\'s events (visits, offer clicks, sales, custom events, refunds).' },
1062
+ offer_id: { type: 'number', description: 'Only events on clicks that went to this offer.' },
1063
+ property_id: { type: 'number', description: 'Only this property\'s page events.' },
1064
+ link_id: { type: 'number', description: 'Only this plain short link\'s clicks (shown as event_type "click").' },
751
1065
  event: { type: 'string', description: 'Exact event_type filter, e.g. page_view, outbound, conversion, visit, lander_view, offer_click.' },
752
1066
  utm_source: { type: 'string', description: 'Exact utm_source filter.' },
753
1067
  utm_medium: { type: 'string', description: 'Exact utm_medium filter.' },
754
1068
  utm_campaign: { type: 'string', description: 'Exact utm_campaign filter.' },
755
1069
  utm_term: { type: 'string', description: 'Exact utm_term filter.' },
756
1070
  utm_content: { type: 'string', description: 'Exact utm_content filter.' },
757
- sort: { type: 'string', enum: ['ts', 'revenue', 'utm_campaign', 'utm_source', 'country'], description: 'Sort field. Defaults to ts.' },
1071
+ sort: { type: 'string', enum: ['ts', 'revenue', 'stream', 'source_name', 'event_type', 'country', 'region', 'city', 'ip', 'device', 'browser', 'referrer', 'page_path', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'], description: 'Sort field. Defaults to ts.' },
758
1072
  dir: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction. Defaults to desc.' },
759
1073
  limit: { type: 'number', description: 'Page size (1–500). Defaults to 200.' },
760
1074
  offset: { type: 'number', description: 'Pagination offset. Defaults to 0.' },
@@ -764,7 +1078,7 @@ const TOOLS = [
764
1078
  ];
765
1079
 
766
1080
  const server = new Server(
767
- { name: 'buygolinks', version: '0.11.0' },
1081
+ { name: 'buygolinks', version: PKG_VERSION },
768
1082
  { capabilities: { tools: {} } },
769
1083
  );
770
1084
 
@@ -813,6 +1127,76 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
813
1127
  });
814
1128
  break;
815
1129
  }
1130
+ case 'delete_link': {
1131
+ result = await api(`/api/links/${Number(args.link_id)}`, { method: 'DELETE' });
1132
+ break;
1133
+ }
1134
+ case 'get_link_pixels': {
1135
+ result = await api(`/api/links/${Number(args.link_id)}/pixels`);
1136
+ break;
1137
+ }
1138
+ case 'set_link_pixels': {
1139
+ const body = { pixel_ids: (args.pixel_ids || []).map(Number) };
1140
+ if (typeof args.overridden === 'boolean') body.overridden = args.overridden;
1141
+ if (args.events && typeof args.events === 'object') body.events = args.events;
1142
+ result = await api(`/api/links/${Number(args.link_id)}/pixels`, { method: 'PUT', body: JSON.stringify(body) });
1143
+ break;
1144
+ }
1145
+ case 'set_link_collection': {
1146
+ const cid = args.collection_id == null ? null : Number(args.collection_id);
1147
+ result = await api(`/api/links/${Number(args.link_id)}/collection`, {
1148
+ method: 'POST', body: JSON.stringify({ collection_id: cid }),
1149
+ });
1150
+ break;
1151
+ }
1152
+
1153
+ // ── Collections ──
1154
+ case 'list_collections': {
1155
+ result = await api('/api/collections');
1156
+ break;
1157
+ }
1158
+ case 'get_collection': {
1159
+ result = await api(`/api/collections/${Number(args.collection_id)}${qsFrom(args, ['days'])}`);
1160
+ break;
1161
+ }
1162
+ case 'create_collection':
1163
+ case 'update_collection': {
1164
+ const body = {};
1165
+ for (const k of ['name', 'description', 'color', 'rotator_alias']) {
1166
+ if (typeof args[k] === 'string') body[k] = args[k];
1167
+ }
1168
+ for (const k of ['starred', 'rotator_enabled']) {
1169
+ if (typeof args[k] === 'boolean') body[k] = args[k];
1170
+ }
1171
+ result = name === 'create_collection'
1172
+ ? await api('/api/collections', { method: 'POST', body: JSON.stringify(body) })
1173
+ : await api(`/api/collections/${Number(args.collection_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
1174
+ break;
1175
+ }
1176
+ case 'delete_collection': {
1177
+ result = await api(`/api/collections/${Number(args.collection_id)}`, { method: 'DELETE' });
1178
+ break;
1179
+ }
1180
+
1181
+ // ── Account ──
1182
+ case 'get_account': {
1183
+ result = await api('/api/me');
1184
+ break;
1185
+ }
1186
+ case 'list_api_keys': {
1187
+ result = await api('/api/api-keys');
1188
+ break;
1189
+ }
1190
+ case 'create_api_key': {
1191
+ const body = typeof args?.label === 'string' ? { label: args.label } : {};
1192
+ result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify(body) });
1193
+ break;
1194
+ }
1195
+ case 'delete_api_key': {
1196
+ result = await api(`/api/api-keys/${Number(args.api_key_id)}`, { method: 'DELETE' });
1197
+ break;
1198
+ }
1199
+
816
1200
  case 'list_links': {
817
1201
  const qs = args?.include_archived ? '?include_archived=1' : '';
818
1202
  result = await api(`/api/links${qs}`);
@@ -843,19 +1227,36 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
843
1227
  result = await api(`/api/domains/${Number(args.domain_id)}/verify`, { method: 'POST' });
844
1228
  break;
845
1229
  }
1230
+ case 'check_domain_dns': {
1231
+ result = await api(`/api/domains/${Number(args.domain_id)}/dns`);
1232
+ break;
1233
+ }
1234
+ case 'connect_domain_cloudflare': {
1235
+ result = await api(`/api/domains/${Number(args.domain_id)}/connect-cloudflare`, {
1236
+ method: 'POST',
1237
+ body: JSON.stringify({ api_token: args.api_token }),
1238
+ });
1239
+ break;
1240
+ }
846
1241
  case 'delete_domain': {
847
1242
  result = await api(`/api/domains/${Number(args.domain_id)}`, { method: 'DELETE' });
848
1243
  break;
849
1244
  }
850
1245
 
851
1246
  // ── Tracker: networks ──
1247
+ case 'set_account_timezone': {
1248
+ const tz = args?.timezone === null || args?.timezone === '' ? null : String(args.timezone);
1249
+ await api('/api/me', { method: 'PATCH', body: JSON.stringify({ timezone: tz }) });
1250
+ result = { timezone: tz };
1251
+ break;
1252
+ }
852
1253
  case 'get_universal_postback_url': {
853
1254
  const me = await api('/api/me');
854
1255
  const base = me.user && me.user.postback_url;
855
1256
  result = {
856
1257
  postback_url: base,
857
1258
  template: base ? `${base}?clickid=REPLACE&payout=OPTIONAL&currency=OPTIONAL&txid=OPTIONAL` : null,
858
- note: 'Universal: works for every campaign and every network. payout/currency/txid are optional; cid is accepted as an alias for clickid.',
1259
+ note: 'Universal: works for every campaign and every network. payout/currency/txid are optional; a sale sent with no payout books the payout of the offer its click went to, when the offer has one. cid is accepted as an alias for clickid.',
859
1260
  };
860
1261
  break;
861
1262
  }
@@ -866,9 +1267,6 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
866
1267
  case 'create_affiliate_network': {
867
1268
  const body = { name: args.name };
868
1269
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
869
- if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
870
- if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
871
- if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
872
1270
  if (typeof args.amazon_tag === 'string') body.amazon_tag = args.amazon_tag;
873
1271
  result = await api('/api/affiliate-networks', { method: 'POST', body: JSON.stringify(body) });
874
1272
  break;
@@ -877,9 +1275,6 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
877
1275
  const body = {};
878
1276
  if (typeof args.name === 'string') body.name = args.name;
879
1277
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
880
- if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
881
- if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
882
- if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
883
1278
  if (typeof args.amazon_tag === 'string') body.amazon_tag = args.amazon_tag;
884
1279
  if (typeof args.archived === 'boolean') body.archived = args.archived;
885
1280
  result = await api(`/api/affiliate-networks/${Number(args.network_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
@@ -898,7 +1293,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
898
1293
  case 'create_offer': {
899
1294
  const body = { name: args.name, url: args.url };
900
1295
  if ('affiliate_network_id' in args) body.affiliate_network_id = args.affiliate_network_id;
901
- if (typeof args.payout === 'number') body.payout = args.payout;
1296
+ if ('payout' in args) body.payout = args.payout;
902
1297
  if (typeof args.payout_currency === 'string') body.payout_currency = args.payout_currency;
903
1298
  if (typeof args.country === 'string') body.country = args.country;
904
1299
  result = await api('/api/offers', { method: 'POST', body: JSON.stringify(body) });
@@ -909,7 +1304,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
909
1304
  if (typeof args.name === 'string') body.name = args.name;
910
1305
  if (typeof args.url === 'string') body.url = args.url;
911
1306
  if ('affiliate_network_id' in args) body.affiliate_network_id = args.affiliate_network_id;
912
- if (typeof args.payout === 'number') body.payout = args.payout;
1307
+ if ('payout' in args) body.payout = args.payout;
913
1308
  if (typeof args.payout_currency === 'string') body.payout_currency = args.payout_currency;
914
1309
  if (typeof args.country === 'string') body.country = args.country;
915
1310
  if (typeof args.archived === 'boolean') body.archived = args.archived;
@@ -946,9 +1341,60 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
946
1341
  break;
947
1342
  }
948
1343
 
1344
+ // ── Custom postback events ──
1345
+ case 'list_custom_events': {
1346
+ result = await api('/api/custom-events');
1347
+ break;
1348
+ }
1349
+ case 'create_custom_event': {
1350
+ const body = { label: args.label };
1351
+ if (typeof args.slug === 'string') body.slug = args.slug;
1352
+ if (typeof args.counts_revenue === 'boolean') body.counts_revenue = args.counts_revenue;
1353
+ result = await api('/api/custom-events', { method: 'POST', body: JSON.stringify(body) });
1354
+ break;
1355
+ }
1356
+ case 'update_custom_event': {
1357
+ const body = {};
1358
+ if (typeof args.label === 'string') body.label = args.label;
1359
+ if (typeof args.counts_revenue === 'boolean') body.counts_revenue = args.counts_revenue;
1360
+ result = await api(`/api/custom-events/${Number(args.custom_event_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
1361
+ break;
1362
+ }
1363
+ case 'delete_custom_event': {
1364
+ result = await api(`/api/custom-events/${Number(args.custom_event_id)}`, { method: 'DELETE' });
1365
+ break;
1366
+ }
1367
+
1368
+ // ── Report views ──
1369
+ case 'list_report_views': {
1370
+ result = await api('/api/report-views');
1371
+ break;
1372
+ }
1373
+ case 'create_report_view': {
1374
+ const body = { name: args.name, columns: args.columns };
1375
+ if (typeof args.activate === 'boolean') body.activate = args.activate;
1376
+ result = await api('/api/report-views', { method: 'POST', body: JSON.stringify(body) });
1377
+ break;
1378
+ }
1379
+ case 'update_report_view': {
1380
+ const body = {};
1381
+ if (typeof args.name === 'string') body.name = args.name;
1382
+ if (Array.isArray(args.columns)) body.columns = args.columns;
1383
+ result = await api(`/api/report-views/${Number(args.report_view_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
1384
+ break;
1385
+ }
1386
+ case 'delete_report_view': {
1387
+ result = await api(`/api/report-views/${Number(args.report_view_id)}`, { method: 'DELETE' });
1388
+ break;
1389
+ }
1390
+ case 'set_active_report_view': {
1391
+ result = await api('/api/report-views/active', { method: 'PUT', body: JSON.stringify({ view: String(args.view) }) });
1392
+ break;
1393
+ }
1394
+
949
1395
  // ── Traffic sources ──
950
1396
  case 'list_traffic_sources': {
951
- result = await api('/api/traffic-sources');
1397
+ result = await api(`/api/traffic-sources${args?.include_archived ? '?include_archived=1' : ''}`);
952
1398
  break;
953
1399
  }
954
1400
  case 'create_traffic_source': {
@@ -982,6 +1428,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
982
1428
  if (typeof args.label === 'string') body.label = args.label;
983
1429
  if (typeof args.default_event === 'string') body.default_event = args.default_event;
984
1430
  if (typeof args.capi_token === 'string') body.capi_token = args.capi_token;
1431
+ if (typeof args.test_event_code === 'string') body.test_event_code = args.test_event_code;
985
1432
  if (typeof args.active === 'boolean') body.active = args.active;
986
1433
  result = await api('/api/tracking-pixels', { method: 'POST', body: JSON.stringify(body) });
987
1434
  break;
@@ -992,10 +1439,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
992
1439
  if (typeof args.label === 'string') body.label = args.label;
993
1440
  if (typeof args.default_event === 'string') body.default_event = args.default_event;
994
1441
  if ('capi_token' in args) body.capi_token = args.capi_token;
1442
+ if ('test_event_code' in args) body.test_event_code = args.test_event_code;
995
1443
  if (typeof args.active === 'boolean') body.active = args.active;
996
1444
  result = await api(`/api/tracking-pixels/${Number(args.pixel_id_row)}`, { method: 'PATCH', body: JSON.stringify(body) });
997
1445
  break;
998
1446
  }
1447
+ case 'test_tracking_pixel': {
1448
+ const body = typeof args.event === 'string' ? { event: args.event } : {};
1449
+ result = await api(`/api/tracking-pixels/${Number(args.pixel_id_row)}/test`, { method: 'POST', body: JSON.stringify(body) });
1450
+ break;
1451
+ }
999
1452
  case 'delete_tracking_pixel': {
1000
1453
  result = await api(`/api/tracking-pixels/${Number(args.pixel_id_row)}`, { method: 'DELETE' });
1001
1454
  break;
@@ -1003,7 +1456,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1003
1456
 
1004
1457
  // ── Tracker: campaigns ──
1005
1458
  case 'list_campaigns': {
1006
- result = await api('/api/campaigns');
1459
+ result = await api(`/api/campaigns${args?.include_archived ? '?include_archived=1' : ''}`);
1007
1460
  break;
1008
1461
  }
1009
1462
  case 'create_campaign': {
@@ -1015,6 +1468,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1015
1468
  if ('domain_id' in args) body.domain_id = args.domain_id;
1016
1469
  if ('traffic_source_id' in args) body.traffic_source_id = args.traffic_source_id;
1017
1470
  if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
1471
+ if ('pixel_events' in args) body.pixel_events = args.pixel_events;
1018
1472
  if (typeof args.report_value_mode === 'string') body.report_value_mode = args.report_value_mode;
1019
1473
  if ('report_value_fixed' in args) body.report_value_fixed = args.report_value_fixed;
1020
1474
  if ('report_value_adjust_pct' in args) body.report_value_adjust_pct = args.report_value_adjust_pct;
@@ -1022,6 +1476,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1022
1476
  if ('direct_slug' in args) body.direct_slug = args.direct_slug;
1023
1477
  if (Array.isArray(args.direct_slugs)) body.direct_slugs = args.direct_slugs;
1024
1478
  if ('fallback_url' in args) body.fallback_url = args.fallback_url;
1479
+ if ('open_in_browser' in args) body.open_in_browser = args.open_in_browser;
1025
1480
  if (Array.isArray(args.offer_ids)) body.offer_ids = args.offer_ids;
1026
1481
  if (Array.isArray(args.offer_weights)) body.offer_weights = args.offer_weights;
1027
1482
  if (Array.isArray(args.lander_ids)) body.lander_ids = args.lander_ids;
@@ -1043,6 +1498,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1043
1498
  if (typeof args.cost_value === 'number') body.cost_value = args.cost_value;
1044
1499
  if ('domain_id' in args) body.domain_id = args.domain_id;
1045
1500
  if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
1501
+ if ('pixel_events' in args) body.pixel_events = args.pixel_events;
1046
1502
  if (typeof args.report_value_mode === 'string') body.report_value_mode = args.report_value_mode;
1047
1503
  if ('report_value_fixed' in args) body.report_value_fixed = args.report_value_fixed;
1048
1504
  if ('report_value_adjust_pct' in args) body.report_value_adjust_pct = args.report_value_adjust_pct;
@@ -1050,6 +1506,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1050
1506
  if ('direct_slug' in args) body.direct_slug = args.direct_slug;
1051
1507
  if (Array.isArray(args.direct_slugs)) body.direct_slugs = args.direct_slugs;
1052
1508
  if ('fallback_url' in args) body.fallback_url = args.fallback_url;
1509
+ if ('open_in_browser' in args) body.open_in_browser = args.open_in_browser;
1510
+ if ('dim_labels' in args) body.dim_labels = args.dim_labels;
1053
1511
  if (typeof args.archived === 'boolean') body.archived = args.archived;
1054
1512
  result = await api(`/api/campaigns/${Number(args.campaign_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
1055
1513
  break;
@@ -1089,16 +1547,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1089
1547
  if (args?.from) qs.set('from', String(args.from));
1090
1548
  if (args?.to) qs.set('to', String(args.to));
1091
1549
  if (args?.days && !args?.from) qs.set('days', String(Number(args.days)));
1092
- for (const dim of ['utm_campaign', 'utm_source', 'utm_content', 'utm_term', 'lander', 'offer', 'country', 'device', 'day']) {
1550
+ if (args?.tz) qs.set('tz', String(args.tz));
1551
+ for (const dim of ['utm_campaign', 'utm_medium', 'utm_source', 'utm_content', 'utm_term', 'lander', 'offer', 'country', 'device', 'day']) {
1093
1552
  if (args?.[dim]) qs.set(dim, String(args[dim]));
1094
1553
  }
1554
+ for (const [dim, v] of Object.entries(args?.filters || {})) {
1555
+ if (v) qs.set(dim, String(v));
1556
+ }
1557
+ if (Array.isArray(args?.group) && args.group.length) qs.set('group', args.group.join(','));
1558
+ if (args?.breakdowns === false) qs.set('breakdowns', '0');
1095
1559
  const q = qs.toString();
1096
1560
  result = await api(`/api/campaigns/${Number(args.campaign_id)}/stats${q ? `?${q}` : ''}`);
1097
1561
  break;
1098
1562
  }
1099
1563
 
1100
1564
  case 'list_properties': {
1101
- result = await api('/api/properties');
1565
+ result = await api(`/api/properties${args?.include_archived ? '?include_archived=1' : ''}`);
1102
1566
  break;
1103
1567
  }
1104
1568
  case 'create_property': {
@@ -1132,8 +1596,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1132
1596
  break;
1133
1597
  }
1134
1598
  case 'get_property_stats': {
1135
- const days = args?.days ? `?days=${Number(args.days)}` : '';
1136
- result = await api(`/api/properties/${Number(args.property_id)}/stats${days}`);
1599
+ const qs = new URLSearchParams();
1600
+ if (args?.days) qs.set('days', String(Number(args.days)));
1601
+ if (args?.tz) qs.set('tz', String(args.tz));
1602
+ const q = qs.toString();
1603
+ result = await api(`/api/properties/${Number(args.property_id)}/stats${q ? `?${q}` : ''}`);
1137
1604
  break;
1138
1605
  }
1139
1606
  case 'list_property_pages': {
@@ -1146,17 +1613,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1146
1613
  }
1147
1614
 
1148
1615
  case 'get_tracker_overview': {
1149
- const days = args?.days ? `?days=${Number(args.days)}` : '';
1150
- result = await api(`/api/tracker/overview${days}`);
1616
+ const a = { ...(args || {}) };
1617
+ if (a.from) delete a.days;
1618
+ result = await api(`/api/tracker/overview${qsFrom(a, ['days', 'from', 'to', 'tz', 'offer_id', 'property_id'])}`);
1151
1619
  break;
1152
1620
  }
1153
1621
  case 'get_tracker_events': {
1154
- const qs = new URLSearchParams();
1155
- for (const k of ['days', 'q', 'stream', 'event', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'sort', 'dir', 'limit', 'offset']) {
1156
- if (args?.[k] !== undefined && args[k] !== null && args[k] !== '') qs.set(k, String(args[k]));
1157
- }
1158
- const q = qs.toString();
1159
- result = await api(`/api/tracker/events${q ? `?${q}` : ''}`);
1622
+ const a = { ...(args || {}) };
1623
+ if (a.from) delete a.days;
1624
+ result = await api(`/api/tracker/events${qsFrom(a, ['days', 'from', 'to', 'q', 'stream', 'event', 'campaign_id', 'offer_id', 'property_id', 'link_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'sort', 'dir', 'limit', 'offset'])}`);
1160
1625
  break;
1161
1626
  }
1162
1627