@buygolinks/mcp 0.9.0 → 0.17.1

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 +11 -5
  2. package/package.json +1 -1
  3. package/src/index.js +323 -16
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @buygolinks/mcp
2
2
 
3
- Model Context Protocol (MCP) server for [buygolinks.com](https://buygolinks.com) — create and manage branded short links (with Amazon affiliate geo-routing) from any MCP-compatible AI agent: Claude Desktop, Cursor, Cline, Continue, Hermes, and others.
3
+ Model Context Protocol (MCP) server for [buygolinks.com](https://buygolinks.com) — create and manage branded short links, campaigns, offers, and analytics from any MCP-compatible AI agent: Claude Desktop, Cursor, Cline, Continue, Hermes, and others.
4
4
 
5
5
  ## Install
6
6
 
@@ -62,8 +62,8 @@ Most MCP clients use the same shape. Point them at the `npx -y @buygolinks/mcp`
62
62
 
63
63
  | Tool | Description |
64
64
  |---|---|
65
- | `create_link` | Create a short link. Amazon URLs auto-detect ASIN and trigger geo-routing. |
66
- | `update_link` | Edit a link's title, source label, custom domain, password, archive state, or custom alias (e.g. `summer-sale` → `https://click.example.com/summer-sale`). |
65
+ | `create_link` | Create a short link. The destination URL is passed through untouched (include your own affiliate tag). |
66
+ | `update_link` | Edit a link's destination URL, title, source label, custom domain, password, archive state, or custom alias (e.g. `summer-sale` → `https://click.example.com/summer-sale`). Repointing the destination preserves the short code, alias, and click history. |
67
67
  | `list_links` | List your links with click counts. |
68
68
  | `get_link_stats` | Per-link analytics (by day, country, device, browser, hour, weekday). |
69
69
  | `get_overview_stats` | Account-wide analytics. |
@@ -74,16 +74,22 @@ Most MCP clients use the same shape. Point them at the `npx -y @buygolinks/mcp`
74
74
  | `list_affiliate_networks` / `create_affiliate_network` / `update_affiliate_network` / `delete_affiliate_network` | Manage affiliate networks. Each network exposes a per-network postback URL (`/postback/n/{token}`) the user pastes into the network's S2S settings. |
75
75
  | `list_offers` / `create_offer` / `update_offer` / `delete_offer` | Manage destination URLs ("offers"). Optionally tied to an affiliate network so the right conversion-param name is propagated on outbound clicks. |
76
76
  | `list_landers` / `create_lander` / `update_lander` / `delete_lander` | Manage landing pages. Lander URLs may contain `{clickid}` tokens which the resolver expands. |
77
- | `list_campaigns` / `create_campaign` / `get_campaign` / `update_campaign` / `delete_campaign` | Manage campaigns. One campaign = one tracked short link + N offers (weighted) + N optional landers + a per-campaign postback URL + a pixel snippet. |
77
+ | `list_traffic_sources` / `create_traffic_source` / `update_traffic_source` / `delete_traffic_source` | Manage **traffic sources** — the platforms you buy traffic from (Facebook, TikTok, Google, …). Each has a `name`, optional `platform`, and `token_params` mapping the source's click macros onto sub1..sub5 (e.g. Facebook sub1 = Ad ID `{{ad.id}}`). `list_traffic_sources` returns a `presets` catalog. Campaigns reference one via `traffic_source_id`. |
78
+ | `list_tracking_pixels` / `create_tracking_pixel` / `update_tracking_pixel` / `delete_tracking_pixel` | Manage ad-platform pixels (Facebook, Google, TikTok, …). Each pixel has a `default_event` (e.g. `Purchase`, `Lead`, `CompletePayment`) and, for Facebook/TikTok, an optional server-side Conversions API `capi_token`. `list_tracking_pixels` also returns a `platforms` catalog with each platform's valid `events`, `defaultEvent`, and `capi` support. Pixels fire on short-link clicks and can be chosen as a campaign's conversion pixel. |
79
+ | `list_campaigns` / `create_campaign` / `get_campaign` / `update_campaign` / `delete_campaign` / `duplicate_campaign` | Manage campaigns. `duplicate_campaign` deep-copies a campaign (settings + full routing flow + offer/lander pools) into a fresh short link named `COPY - {name}` (`COPY (1) - …` when copying a copy); stats and the unique direct-link slug are not carried over. One campaign = one tracked short link + N offers (weighted) + N optional landers + a per-campaign postback URL + a pixel snippet. A campaign references one saved traffic source via `traffic_source_id` and an optional `conversion_pixel_id` + `conversion_event` that fires (server-side via CAPI when configured) when the campaign converts. Each campaign also returns a `click_url` (`…/click`) — the single, campaign-agnostic lander CTA link (same on every campaign, so one lander serves many) that always resolves to an offer (never a dead page); the campaign is read from the visitor's cookie or the ad's `?utm_campaign={direct_slug}` forwarded onto the CTA. Set `direct_slug` to run traffic straight to a lander with no tracker hop (`?utm_campaign={direct_slug}` resolves the campaign at `/click` and stitches via the property tag); `fallback_url` is the never-dead-end guarantee. |
78
80
  | `set_campaign_offers` / `set_campaign_landers` | Replace the campaign's offer/lander split. Items are `{ offer_id, weight }` / `{ lander_id, weight }`. |
81
+ | `get_campaign_flow` / `set_campaign_flow` | Read/replace the campaign's **routing flow graph** (n8n-style). Nodes are `source` (one entry) / `lander` / `offer`; weighted edges go source→lander, source→offer, or lander→offer. The resolver walks this graph per click so a specific lander routes to its own specific offers — use it to split-test offers, divert a lander to a different offer, or insert a landing page before an offer. Saving also re-derives the flat offer/lander pools. |
79
82
  | `get_campaign_stats` | Full campaign report: visits, lander views, offer clicks, conversions, revenue, cost, profit, CTR/CR/EPC/ROI, breakdowns, recent events. |
80
83
  | `list_properties` / `create_property` / `get_property` / `update_property` / `delete_property` | Manage **properties** — GTM/GA-style site containers. One property = one universal tag (`/t.js?p={key}`) the user installs on every page of their site. Every page auto-reports pageviews, time-on-page, scroll, outbound clicks, and conversions; campaign traffic auto-attributes by click. `create_property` requires an active custom domain (the tag is served first-party from the user's own domain so it tracks on iOS/Safari) and returns the bound `tracking_domain` + install `snippet`. |
81
84
  | `set_property_campaigns` | Link/unlink campaigns to a property (many-to-many, for the source split in reports). Items are `{ campaign_id }`. |
82
85
  | `get_property_stats` | Full property report: page views, unique visitors, outbound, conversions, revenue, avg time/scroll, top pages, source split (organic vs campaign), country/device/browser/referrer breakdowns, recent events. |
86
+ | `list_property_pages` | Distinct pages seen across all the user's properties (most-viewed first): `{ url, path, title, property_name, views }`. Any page here can be used as a campaign **lander** — pass its `url` to `create_lander`, then wire that lander into a campaign flow with `set_campaign_flow`. |
87
+ | `get_tracker_overview` | Unified Tracker dashboard across **both** streams (sites + campaigns) in one call: totals (page views / visitors / visits / offer clicks / conversions / revenue), daily activity, country/device/browser breakdowns, per-property + per-campaign rollups, a **per-UTM-campaign** rollup (auto-detected Google `utm_*` tags), and the 50 most recent events. The "is everything running and getting traffic?" view. |
88
+ | `get_tracker_events` | Unified event log across both streams with full **search / filter / sort** over auto-detected UTM tags. `q` free-text, `stream`, `event`, exact `utm_*` filters, `sort`/`dir` (ts\|revenue\|utm_campaign\|utm_source\|country), pagination. Returns events + `facets` (distinct UTM values) + `total`. |
83
89
 
84
90
  ## Example prompts
85
91
 
86
- > "Create a short link for `https://www.amazon.com/dp/B0CFG6RB6T` with the title 'Anker GaN charger'."
92
+ > "Create a short link for `https://www.amazon.com/dp/B0CFG6RB6T?tag=mytag-20` with the title 'Anker GaN charger'."
87
93
 
88
94
  > "How many clicks did I get last week? Which country drove the most?"
89
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buygolinks/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.17.1",
4
4
  "description": "MCP server for buygolinks.com — create and manage branded short links with Amazon affiliate geo-routing from any MCP-compatible AI agent (Claude Desktop, Cursor, Cline, Continue, Hermes).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -48,7 +48,7 @@ const TOOLS = [
48
48
  {
49
49
  name: 'create_link',
50
50
  description:
51
- 'Create a new branded short link on buygolinks.com. If the destination is an Amazon product URL (contains /dp/ASIN), the ASIN is auto-extracted and the link will geo-route to the visitor\'s local Amazon storefront with the owner\'s affiliate tag applied. 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).',
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).',
52
52
  inputSchema: {
53
53
  type: 'object',
54
54
  properties: {
@@ -58,11 +58,11 @@ const TOOLS = [
58
58
  },
59
59
  title: {
60
60
  type: 'string',
61
- description: 'Optional human-readable title for the link. If omitted on an Amazon URL, the product title is scraped.',
61
+ description: 'Optional human-readable title for the link.',
62
62
  },
63
63
  source_label: {
64
64
  type: 'string',
65
- description: 'Optional source label (campaign tag) — appears in ascsubtag on Amazon redirects for attribution.',
65
+ description: 'Optional source label (campaign tag) for your own attribution.',
66
66
  },
67
67
  domain_id: {
68
68
  type: 'number',
@@ -75,11 +75,12 @@ const TOOLS = [
75
75
  {
76
76
  name: 'update_link',
77
77
  description:
78
- 'Update an existing short link. Supports 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 `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).',
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).',
79
79
  inputSchema: {
80
80
  type: 'object',
81
81
  properties: {
82
82
  link_id: { type: 'number', description: 'The numeric id of the link (from list_links / create_link).' },
83
+ destination_url: { type: 'string', description: 'New destination URL the short link redirects to (must be http(s)). The short code, alias, and click history are preserved.' },
83
84
  title: { type: 'string', description: 'New display title.' },
84
85
  source_label: { type: 'string', description: 'New source label (campaign tag).' },
85
86
  archived: { type: 'boolean', description: 'Set true to archive the link, false to unarchive.' },
@@ -126,7 +127,7 @@ const TOOLS = [
126
127
  {
127
128
  name: 'get_overview_stats',
128
129
  description:
129
- 'Get account-wide click analytics across all of the user\'s links: total clicks, clicks by day, top countries, devices, browsers, referrers, peak hour and weekday. Use this for "how is my account doing" type questions.',
130
+ '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.',
130
131
  inputSchema: {
131
132
  type: 'object',
132
133
  properties: {
@@ -193,7 +194,7 @@ const TOOLS = [
193
194
  {
194
195
  name: 'create_affiliate_network',
195
196
  description:
196
- '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. Returns the network row including postback_url.',
197
+ '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.',
197
198
  inputSchema: {
198
199
  type: 'object',
199
200
  properties: {
@@ -201,6 +202,7 @@ const TOOLS = [
201
202
  conversion_param: { type: 'string', description: 'What the network calls the click id in its postback (default "clickid").' },
202
203
  payout_param: { type: 'string', description: 'What the network calls the payout amount (default "payout").' },
203
204
  currency_param: { type: 'string', description: 'What the network calls the currency code (default "currency").' },
205
+ 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.' },
204
206
  },
205
207
  required: ['name'],
206
208
  },
@@ -216,6 +218,7 @@ const TOOLS = [
216
218
  conversion_param: { type: 'string' },
217
219
  payout_param: { type: 'string' },
218
220
  currency_param: { type: 'string' },
221
+ txid_param: { type: 'string' },
219
222
  archived: { type: 'boolean' },
220
223
  },
221
224
  required: ['network_id'],
@@ -240,7 +243,7 @@ const TOOLS = [
240
243
  {
241
244
  name: 'create_offer',
242
245
  description:
243
- 'Create an offer (a destination URL: affiliate landing page, product page, etc). If the URL is an Amazon /dp/ link the ASIN is auto-extracted. 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=…).',
246
+ '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=…).',
244
247
  inputSchema: {
245
248
  type: 'object',
246
249
  properties: {
@@ -319,6 +322,105 @@ const TOOLS = [
319
322
  inputSchema: { type: 'object', properties: { lander_id: { type: 'number' } }, required: ['lander_id'] },
320
323
  },
321
324
 
325
+ // ─── Traffic sources ────────────────────────────────────────────────────
326
+ {
327
+ name: 'list_traffic_sources',
328
+ description:
329
+ '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).',
330
+ inputSchema: { type: 'object', properties: {} },
331
+ },
332
+ {
333
+ name: 'create_traffic_source',
334
+ description:
335
+ 'Create a traffic source. `token_params` is an array of `{ slot, label, token }` where slot is one of utm_source/utm_medium/utm_campaign/utm_term/utm_content, label is a human name (e.g. "Campaign ID"), and token is the platform macro (e.g. "{{campaign.id}}"). Use `list_traffic_sources` first to copy a preset\'s token_params for the big platforms.',
336
+ inputSchema: {
337
+ type: 'object',
338
+ properties: {
339
+ name: { type: 'string', description: 'e.g. "Facebook".' },
340
+ platform: { type: ['string', 'null'], description: 'Optional pixel-platform key (facebook, google_ads, tiktok, bing, …) for the icon + conversion-pixel suggestion.' },
341
+ token_params: {
342
+ type: 'array',
343
+ items: {
344
+ type: 'object',
345
+ properties: {
346
+ slot: { type: 'string', enum: ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] },
347
+ label: { type: 'string' },
348
+ token: { type: 'string' },
349
+ },
350
+ required: ['slot'],
351
+ },
352
+ },
353
+ },
354
+ required: ['name'],
355
+ },
356
+ },
357
+ {
358
+ name: 'update_traffic_source',
359
+ description: 'Update a traffic source\'s name, platform, token_params, or archive state. token_params replaces the whole set.',
360
+ inputSchema: {
361
+ type: 'object',
362
+ properties: {
363
+ traffic_source_id: { type: 'number' },
364
+ name: { type: 'string' },
365
+ platform: { type: ['string', 'null'] },
366
+ token_params: { type: 'array', items: { type: 'object', properties: { slot: { type: 'string' }, label: { type: 'string' }, token: { type: 'string' } }, required: ['slot'] } },
367
+ archived: { type: 'boolean' },
368
+ },
369
+ required: ['traffic_source_id'],
370
+ },
371
+ },
372
+ {
373
+ name: 'delete_traffic_source',
374
+ description: 'Delete a traffic source. Campaigns using it keep their data but their `traffic_source_id` is cleared.',
375
+ inputSchema: { type: 'object', properties: { traffic_source_id: { type: 'number' } }, required: ['traffic_source_id'] },
376
+ },
377
+
378
+ // ─── Tracking pixels ────────────────────────────────────────────────────
379
+ {
380
+ name: 'list_tracking_pixels',
381
+ description:
382
+ '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.',
383
+ inputSchema: { type: 'object', properties: {} },
384
+ },
385
+ {
386
+ name: 'create_tracking_pixel',
387
+ description:
388
+ '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.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ platform: { type: 'string', description: 'Pixel platform key (facebook, google_ads, tiktok, …).' },
393
+ pixel_id: { type: 'string', description: 'The platform\'s pixel/dataset id.' },
394
+ label: { type: 'string', description: 'Optional friendly name.' },
395
+ default_event: { type: 'string', description: 'Event this pixel fires by default (must be valid for the platform).' },
396
+ capi_token: { type: 'string', description: 'Optional server-side Conversions API access token (Facebook/TikTok only).' },
397
+ active: { type: 'boolean', description: 'Whether the pixel is active. Defaults to true.' },
398
+ },
399
+ required: ['platform', 'pixel_id'],
400
+ },
401
+ },
402
+ {
403
+ name: 'update_tracking_pixel',
404
+ 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.',
405
+ inputSchema: {
406
+ type: 'object',
407
+ properties: {
408
+ pixel_id_row: { type: 'number', description: 'The id of the tracking_pixels row to update (from list_tracking_pixels).' },
409
+ pixel_id: { type: 'string', description: 'New platform pixel id.' },
410
+ label: { type: 'string' },
411
+ default_event: { type: 'string', description: 'New default event (must be valid for the pixel\'s platform).' },
412
+ capi_token: { type: ['string', 'null'], description: 'New Conversions API token, or empty/null to clear.' },
413
+ active: { type: 'boolean' },
414
+ },
415
+ required: ['pixel_id_row'],
416
+ },
417
+ },
418
+ {
419
+ name: 'delete_tracking_pixel',
420
+ 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).',
421
+ inputSchema: { type: 'object', properties: { pixel_id_row: { type: 'number' } }, required: ['pixel_id_row'] },
422
+ },
423
+
322
424
  // ─── Tracker: campaigns ─────────────────────────────────────────────────
323
425
  {
324
426
  name: 'list_campaigns',
@@ -329,17 +431,23 @@ const TOOLS = [
329
431
  {
330
432
  name: 'create_campaign',
331
433
  description:
332
- '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). 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.',
434
+ '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.',
333
435
  inputSchema: {
334
436
  type: 'object',
335
437
  properties: {
336
438
  name: { type: 'string' },
337
- traffic_source_label: { type: 'string', description: 'Free text, e.g. "Facebook", "TikTok".' },
439
+ 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.' },
440
+ 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`.' },
441
+ traffic_source_label: { type: 'string', description: 'Free text display label.' },
338
442
  country: { type: 'string', description: 'ISO2 default geo.' },
339
443
  cost_model: { type: 'string', enum: ['not_tracked', 'cpc', 'cpm'] },
340
444
  cost_value: { type: 'number' },
341
445
  transition_mode: { type: 'string', enum: ['302', 'meta_refresh'] },
342
446
  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.' },
447
+ 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.' },
448
+ 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.' },
449
+ direct_slug: { type: ['string', 'null'], description: 'Optional direct-link tag, unique per user (lowercased alnum + dashes, e.g. "bsu-fb-cold"). For running traffic STRAIGHT to a lander with no tracker hop: put `?utm_campaign={direct_slug}` on the ad URL and the property tag stitches the page view to this campaign. Returns 409 (direct_slug_taken) if another campaign already uses it. Pass null to clear.' },
450
+ 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.' },
343
451
  offer_ids: { type: 'array', items: { type: 'number' } },
344
452
  offer_weights: { type: 'array', items: { type: 'number' } },
345
453
  lander_ids: { type: 'array', items: { type: 'number' } },
@@ -355,18 +463,24 @@ const TOOLS = [
355
463
  },
356
464
  {
357
465
  name: 'update_campaign',
358
- description: 'Update a campaign\'s name, country, cost model/value, transition mode, traffic source label, branded domain, or archive state. Use set_campaign_offers / set_campaign_landers to change the destination wiring. Pass domain_id to move the campaign\'s short link to a different custom domain, or null to send it back to buygolinks.com.',
466
+ description: 'Update a campaign\'s name, structured traffic source, country, cost model/value, transition mode, traffic source label, branded domain, conversion pixel/event, direct-link slug, fallback URL, or archive state. Use set_campaign_offers / set_campaign_landers (or set_campaign_flow) to change the destination wiring. Pass domain_id to move the campaign\'s short link to a different custom domain, or null to send it back to buygolinks.com.',
359
467
  inputSchema: {
360
468
  type: 'object',
361
469
  properties: {
362
470
  campaign_id: { type: 'number' },
363
471
  name: { type: 'string' },
472
+ traffic_source_id: { type: ['number', 'null'], description: 'Id of a saved traffic source (from list_traffic_sources), or null to clear. Preferred over `traffic_source`.' },
473
+ traffic_source: { type: ['string', 'null'], description: 'Legacy structured traffic source key or null to clear.' },
364
474
  traffic_source_label: { type: 'string' },
365
475
  country: { type: 'string' },
366
476
  cost_model: { type: 'string', enum: ['not_tracked', 'cpc', 'cpm'] },
367
477
  cost_value: { type: 'number' },
368
478
  transition_mode: { type: 'string', enum: ['302', 'meta_refresh'] },
369
479
  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.' },
480
+ conversion_pixel_id: { type: ['number', 'null'], description: 'Id of a tracking pixel to fire on conversion, or null to clear.' },
481
+ conversion_event: { type: 'string', description: 'Event the conversion pixel fires (must be valid for that pixel\'s platform).' },
482
+ direct_slug: { type: ['string', 'null'], description: 'Direct-link tag for utm_campaign-based stitching (unique per user), or null to clear. Returns 409 (direct_slug_taken) on collision.' },
483
+ fallback_url: { type: ['string', 'null'], description: 'Never-dead-end fallback URL when no live offer exists, or null to use the platform default.' },
370
484
  archived: { type: 'boolean' },
371
485
  },
372
486
  required: ['campaign_id'],
@@ -377,6 +491,11 @@ const TOOLS = [
377
491
  description: 'Delete a campaign. The short link stops resolving immediately. Tracker events for this campaign are also deleted (cascade).',
378
492
  inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
379
493
  },
494
+ {
495
+ name: 'duplicate_campaign',
496
+ 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.',
497
+ inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
498
+ },
380
499
  {
381
500
  name: 'set_campaign_offers',
382
501
  description: 'Replace the campaign\'s offer split with the supplied items. Each item is { offer_id, weight }. Weight is an integer; higher = more traffic. To direct all traffic to one offer use weight=100 for that offer and don\'t include the others.',
@@ -415,6 +534,54 @@ const TOOLS = [
415
534
  required: ['campaign_id', 'items'],
416
535
  },
417
536
  },
537
+ {
538
+ name: 'get_campaign_flow',
539
+ description:
540
+ '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.',
541
+ inputSchema: {
542
+ type: 'object',
543
+ properties: { campaign_id: { type: 'number' } },
544
+ required: ['campaign_id'],
545
+ },
546
+ },
547
+ {
548
+ name: 'set_campaign_flow',
549
+ description:
550
+ '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.',
551
+ inputSchema: {
552
+ type: 'object',
553
+ properties: {
554
+ campaign_id: { type: 'number' },
555
+ nodes: {
556
+ type: 'array',
557
+ items: {
558
+ type: 'object',
559
+ properties: {
560
+ tmp_id: { type: 'string' },
561
+ kind: { type: 'string', enum: ['source', 'lander', 'offer'] },
562
+ ref_id: { type: ['number', 'null'] },
563
+ pos_x: { type: 'number' },
564
+ pos_y: { type: 'number' },
565
+ },
566
+ required: ['tmp_id', 'kind'],
567
+ },
568
+ },
569
+ edges: {
570
+ type: 'array',
571
+ items: {
572
+ type: 'object',
573
+ properties: {
574
+ from: { type: 'string' },
575
+ to: { type: 'string' },
576
+ weight: { type: 'number' },
577
+ },
578
+ required: ['from', 'to'],
579
+ },
580
+ },
581
+ },
582
+ required: ['campaign_id', 'nodes', 'edges'],
583
+ },
584
+ },
418
585
  {
419
586
  name: 'get_campaign_stats',
420
587
  description:
@@ -433,13 +600,13 @@ const TOOLS = [
433
600
  {
434
601
  name: 'list_properties',
435
602
  description:
436
- 'List the user\'s properties with quick-look stats (page_views / unique_visitors / conversions / revenue). 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.',
603
+ '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.',
437
604
  inputSchema: { type: 'object', properties: {} },
438
605
  },
439
606
  {
440
607
  name: 'create_property',
441
608
  description:
442
- '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 <script> 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).',
609
+ '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.',
443
610
  inputSchema: {
444
611
  type: 'object',
445
612
  properties: {
@@ -497,7 +664,7 @@ const TOOLS = [
497
664
  {
498
665
  name: 'get_property_stats',
499
666
  description:
500
- 'Get a property\'s GA-style report: totals (page_views / unique_visitors / outbound / conversions / revenue / avg_time_on_page_ms / avg_scroll_pct / CR), by_day, by_page (top paths with views+conversions+revenue), by_source (organic vs each stitched campaign), by_country / by_device / by_browser / by_referrer, and the 50 most recent events.',
667
+ 'Get a single property\'s GA-style PAGE VIEW report: totals (page_views / unique_visitors / outbound / conversions / revenue / avg_time_on_page_ms / avg_scroll_pct / CR), by_day, by_page (top paths with views+conversions+revenue), by_source (organic vs each stitched campaign), by_country / by_device / by_browser / by_referrer, and the 50 most recent events.',
501
668
  inputSchema: {
502
669
  type: 'object',
503
670
  properties: {
@@ -507,10 +674,50 @@ const TOOLS = [
507
674
  required: ['property_id'],
508
675
  },
509
676
  },
677
+ {
678
+ name: 'list_property_pages',
679
+ description:
680
+ '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.',
681
+ inputSchema: { type: 'object', properties: {} },
682
+ },
683
+ {
684
+ name: 'get_tracker_overview',
685
+ description:
686
+ '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.',
687
+ inputSchema: {
688
+ type: 'object',
689
+ properties: {
690
+ days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14.' },
691
+ },
692
+ },
693
+ },
694
+ {
695
+ name: 'get_tracker_events',
696
+ description:
697
+ '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.',
698
+ inputSchema: {
699
+ type: 'object',
700
+ properties: {
701
+ days: { type: 'number', description: 'Window size in days (1–90). Defaults to 14.' },
702
+ q: { type: 'string', description: 'Free-text search across utm_*, country, device, browser, referrer, source name, event type.' },
703
+ stream: { type: 'string', enum: ['all', 'property', 'campaign'], description: 'Scope to one stream. Defaults to all.' },
704
+ event: { type: 'string', description: 'Exact event_type filter, e.g. page_view, outbound, conversion, visit, lander_view, offer_click.' },
705
+ utm_source: { type: 'string', description: 'Exact utm_source filter.' },
706
+ utm_medium: { type: 'string', description: 'Exact utm_medium filter.' },
707
+ utm_campaign: { type: 'string', description: 'Exact utm_campaign filter.' },
708
+ utm_term: { type: 'string', description: 'Exact utm_term filter.' },
709
+ utm_content: { type: 'string', description: 'Exact utm_content filter.' },
710
+ sort: { type: 'string', enum: ['ts', 'revenue', 'utm_campaign', 'utm_source', 'country'], description: 'Sort field. Defaults to ts.' },
711
+ dir: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction. Defaults to desc.' },
712
+ limit: { type: 'number', description: 'Page size (1–500). Defaults to 200.' },
713
+ offset: { type: 'number', description: 'Pagination offset. Defaults to 0.' },
714
+ },
715
+ },
716
+ },
510
717
  ];
511
718
 
512
719
  const server = new Server(
513
- { name: 'buygolinks', version: '0.8.0' },
720
+ { name: 'buygolinks', version: '0.11.0' },
514
721
  { capabilities: { tools: {} } },
515
722
  );
516
723
 
@@ -536,6 +743,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
536
743
  }
537
744
  case 'update_link': {
538
745
  const body = {};
746
+ if (typeof args.destination_url === 'string') body.destination_url = args.destination_url;
539
747
  if (typeof args.title === 'string') body.title = args.title;
540
748
  if (typeof args.source_label === 'string') body.source_label = args.source_label;
541
749
  if (typeof args.archived === 'boolean') body.archived = args.archived;
@@ -597,6 +805,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
597
805
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
598
806
  if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
599
807
  if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
808
+ if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
600
809
  result = await api('/api/affiliate-networks', { method: 'POST', body: JSON.stringify(body) });
601
810
  break;
602
811
  }
@@ -606,6 +815,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
606
815
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
607
816
  if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
608
817
  if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
818
+ if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
609
819
  if (typeof args.archived === 'boolean') body.archived = args.archived;
610
820
  result = await api(`/api/affiliate-networks/${Number(args.network_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
611
821
  break;
@@ -671,6 +881,61 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
671
881
  break;
672
882
  }
673
883
 
884
+ // ── Traffic sources ──
885
+ case 'list_traffic_sources': {
886
+ result = await api('/api/traffic-sources');
887
+ break;
888
+ }
889
+ case 'create_traffic_source': {
890
+ const body = { name: args.name };
891
+ if ('platform' in args) body.platform = args.platform;
892
+ if (Array.isArray(args.token_params)) body.token_params = args.token_params;
893
+ result = await api('/api/traffic-sources', { method: 'POST', body: JSON.stringify(body) });
894
+ break;
895
+ }
896
+ case 'update_traffic_source': {
897
+ const body = {};
898
+ if (typeof args.name === 'string') body.name = args.name;
899
+ if ('platform' in args) body.platform = args.platform;
900
+ if (Array.isArray(args.token_params)) body.token_params = args.token_params;
901
+ if (typeof args.archived === 'boolean') body.archived = args.archived;
902
+ result = await api(`/api/traffic-sources/${Number(args.traffic_source_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
903
+ break;
904
+ }
905
+ case 'delete_traffic_source': {
906
+ result = await api(`/api/traffic-sources/${Number(args.traffic_source_id)}`, { method: 'DELETE' });
907
+ break;
908
+ }
909
+
910
+ // ── Tracking pixels ──
911
+ case 'list_tracking_pixels': {
912
+ result = await api('/api/tracking-pixels');
913
+ break;
914
+ }
915
+ case 'create_tracking_pixel': {
916
+ const body = { platform: args.platform, pixel_id: args.pixel_id };
917
+ if (typeof args.label === 'string') body.label = args.label;
918
+ if (typeof args.default_event === 'string') body.default_event = args.default_event;
919
+ if (typeof args.capi_token === 'string') body.capi_token = args.capi_token;
920
+ if (typeof args.active === 'boolean') body.active = args.active;
921
+ result = await api('/api/tracking-pixels', { method: 'POST', body: JSON.stringify(body) });
922
+ break;
923
+ }
924
+ case 'update_tracking_pixel': {
925
+ const body = {};
926
+ if (typeof args.pixel_id === 'string') body.pixel_id = args.pixel_id;
927
+ if (typeof args.label === 'string') body.label = args.label;
928
+ if (typeof args.default_event === 'string') body.default_event = args.default_event;
929
+ if ('capi_token' in args) body.capi_token = args.capi_token;
930
+ if (typeof args.active === 'boolean') body.active = args.active;
931
+ result = await api(`/api/tracking-pixels/${Number(args.pixel_id_row)}`, { method: 'PATCH', body: JSON.stringify(body) });
932
+ break;
933
+ }
934
+ case 'delete_tracking_pixel': {
935
+ result = await api(`/api/tracking-pixels/${Number(args.pixel_id_row)}`, { method: 'DELETE' });
936
+ break;
937
+ }
938
+
674
939
  // ── Tracker: campaigns ──
675
940
  case 'list_campaigns': {
676
941
  result = await api('/api/campaigns');
@@ -678,11 +943,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
678
943
  }
679
944
  case 'create_campaign': {
680
945
  const body = { name: args.name };
681
- for (const k of ['traffic_source_label', 'country', 'cost_model', 'transition_mode']) {
946
+ for (const k of ['traffic_source', 'traffic_source_label', 'country', 'cost_model', 'transition_mode', 'conversion_event']) {
682
947
  if (typeof args[k] === 'string') body[k] = args[k];
683
948
  }
684
949
  if (typeof args.cost_value === 'number') body.cost_value = args.cost_value;
685
950
  if ('domain_id' in args) body.domain_id = args.domain_id;
951
+ if ('traffic_source_id' in args) body.traffic_source_id = args.traffic_source_id;
952
+ if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
953
+ if ('direct_slug' in args) body.direct_slug = args.direct_slug;
954
+ if ('fallback_url' in args) body.fallback_url = args.fallback_url;
686
955
  if (Array.isArray(args.offer_ids)) body.offer_ids = args.offer_ids;
687
956
  if (Array.isArray(args.offer_weights)) body.offer_weights = args.offer_weights;
688
957
  if (Array.isArray(args.lander_ids)) body.lander_ids = args.lander_ids;
@@ -696,11 +965,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
696
965
  }
697
966
  case 'update_campaign': {
698
967
  const body = {};
699
- for (const k of ['name', 'traffic_source_label', 'country', 'cost_model', 'transition_mode']) {
968
+ for (const k of ['name', 'traffic_source_label', 'country', 'cost_model', 'transition_mode', 'conversion_event']) {
700
969
  if (typeof args[k] === 'string') body[k] = args[k];
701
970
  }
971
+ if ('traffic_source' in args) body.traffic_source = args.traffic_source;
972
+ if ('traffic_source_id' in args) body.traffic_source_id = args.traffic_source_id;
702
973
  if (typeof args.cost_value === 'number') body.cost_value = args.cost_value;
703
974
  if ('domain_id' in args) body.domain_id = args.domain_id;
975
+ if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
976
+ if ('direct_slug' in args) body.direct_slug = args.direct_slug;
977
+ if ('fallback_url' in args) body.fallback_url = args.fallback_url;
704
978
  if (typeof args.archived === 'boolean') body.archived = args.archived;
705
979
  result = await api(`/api/campaigns/${Number(args.campaign_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
706
980
  break;
@@ -709,6 +983,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
709
983
  result = await api(`/api/campaigns/${Number(args.campaign_id)}`, { method: 'DELETE' });
710
984
  break;
711
985
  }
986
+ case 'duplicate_campaign': {
987
+ result = await api(`/api/campaigns/${Number(args.campaign_id)}/duplicate`, { method: 'POST' });
988
+ break;
989
+ }
712
990
  case 'set_campaign_offers': {
713
991
  result = await api(`/api/campaigns/${Number(args.campaign_id)}/offers`, {
714
992
  method: 'PUT', body: JSON.stringify({ items: args.items }),
@@ -721,6 +999,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
721
999
  });
722
1000
  break;
723
1001
  }
1002
+ case 'get_campaign_flow': {
1003
+ result = await api(`/api/campaigns/${Number(args.campaign_id)}/flow`);
1004
+ break;
1005
+ }
1006
+ case 'set_campaign_flow': {
1007
+ result = await api(`/api/campaigns/${Number(args.campaign_id)}/flow`, {
1008
+ method: 'PUT', body: JSON.stringify({ nodes: args.nodes, edges: args.edges }),
1009
+ });
1010
+ break;
1011
+ }
724
1012
  case 'get_campaign_stats': {
725
1013
  const days = args?.days ? `?days=${Number(args.days)}` : '';
726
1014
  result = await api(`/api/campaigns/${Number(args.campaign_id)}/stats${days}`);
@@ -766,6 +1054,25 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
766
1054
  result = await api(`/api/properties/${Number(args.property_id)}/stats${days}`);
767
1055
  break;
768
1056
  }
1057
+ case 'list_property_pages': {
1058
+ result = await api('/api/property-pages');
1059
+ break;
1060
+ }
1061
+
1062
+ case 'get_tracker_overview': {
1063
+ const days = args?.days ? `?days=${Number(args.days)}` : '';
1064
+ result = await api(`/api/tracker/overview${days}`);
1065
+ break;
1066
+ }
1067
+ case 'get_tracker_events': {
1068
+ const qs = new URLSearchParams();
1069
+ for (const k of ['days', 'q', 'stream', 'event', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'sort', 'dir', 'limit', 'offset']) {
1070
+ if (args?.[k] !== undefined && args[k] !== null && args[k] !== '') qs.set(k, String(args[k]));
1071
+ }
1072
+ const q = qs.toString();
1073
+ result = await api(`/api/tracker/events${q ? `?${q}` : ''}`);
1074
+ break;
1075
+ }
769
1076
 
770
1077
  default:
771
1078
  throw new Error(`unknown tool: ${name}`);