@buygolinks/mcp 0.16.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,8 @@ Most MCP clients use the same shape. Point them at the `npx -y @buygolinks/mcp`
63
63
  | Tool | Description |
64
64
  |---|---|
65
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 title, source label, custom domain, password, archive state, or custom alias (e.g. `summer-sale` → `https://click.example.com/summer-sale`). |
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
+ | `refresh_link_image` | Re-attempt fetching a link's product/preview image (Amazon PDP scrape for `/dp/` links, Open Graph otherwise). Use when a link has no thumbnail or a stale one. Returns `{ found: false }` if nothing could be fetched this time. |
67
68
  | `list_links` | List your links with click counts. |
68
69
  | `get_link_stats` | Per-link analytics (by day, country, device, browser, hour, weekday). |
69
70
  | `get_overview_stats` | Account-wide analytics. |
@@ -76,7 +77,7 @@ Most MCP clients use the same shape. Point them at the `npx -y @buygolinks/mcp`
76
77
  | `list_landers` / `create_lander` / `update_lander` / `delete_lander` | Manage landing pages. Lander URLs may contain `{clickid}` tokens which the resolver expands. |
77
78
  | `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
79
  | `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` | Manage campaigns. 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?cmp={code}`) — the defined lander CTA link that always resolves to an offer (never a dead page). Set `direct_slug` to run traffic straight to a lander with no tracker hop (`?utm_campaign={direct_slug}` stitches via the property tag); `fallback_url` is the never-dead-end guarantee. |
80
+ | `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. |
80
81
  | `set_campaign_offers` / `set_campaign_landers` | Replace the campaign's offer/lander split. Items are `{ offer_id, weight }` / `{ lander_id, weight }`. |
81
82
  | `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. |
82
83
  | `get_campaign_stats` | Full campaign report: visits, lander views, offer clicks, conversions, revenue, cost, profit, CTR/CR/EPC/ROI, breakdowns, recent events. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buygolinks/mcp",
3
- "version": "0.16.0",
3
+ "version": "0.20.0",
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
@@ -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.' },
@@ -90,6 +91,18 @@ const TOOLS = [
90
91
  required: ['link_id'],
91
92
  },
92
93
  },
94
+ {
95
+ name: 'refresh_link_image',
96
+ 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).',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ link_id: { type: 'number', description: 'The numeric id of the link (from list_links / create_link).' },
102
+ },
103
+ required: ['link_id'],
104
+ },
105
+ },
93
106
  {
94
107
  name: 'list_links',
95
108
  description:
@@ -193,7 +206,7 @@ const TOOLS = [
193
206
  {
194
207
  name: 'create_affiliate_network',
195
208
  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.',
209
+ '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
210
  inputSchema: {
198
211
  type: 'object',
199
212
  properties: {
@@ -201,6 +214,7 @@ const TOOLS = [
201
214
  conversion_param: { type: 'string', description: 'What the network calls the click id in its postback (default "clickid").' },
202
215
  payout_param: { type: 'string', description: 'What the network calls the payout amount (default "payout").' },
203
216
  currency_param: { type: 'string', description: 'What the network calls the currency code (default "currency").' },
217
+ 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
218
  },
205
219
  required: ['name'],
206
220
  },
@@ -216,6 +230,7 @@ const TOOLS = [
216
230
  conversion_param: { type: 'string' },
217
231
  payout_param: { type: 'string' },
218
232
  currency_param: { type: 'string' },
233
+ txid_param: { type: 'string' },
219
234
  archived: { type: 'boolean' },
220
235
  },
221
236
  required: ['network_id'],
@@ -428,7 +443,7 @@ const TOOLS = [
428
443
  {
429
444
  name: 'create_campaign',
430
445
  description:
431
- '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?cmp={code}`) — the DEFINED lander click URL the user pastes into their lander\'s CTA button; 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` and put `?utm_campaign={direct_slug}` on the ad URL — the property tag then stitches the page view to this campaign. `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.',
446
+ '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.',
432
447
  inputSchema: {
433
448
  type: 'object',
434
449
  properties: {
@@ -443,6 +458,10 @@ const TOOLS = [
443
458
  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.' },
444
459
  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.' },
445
460
  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.' },
461
+ 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.' },
462
+ 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.' },
463
+ 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%).' },
464
+ report_value_adjust_flat: { type: ['number', 'null'], description: 'Used when report_value_mode = "adjusted": flat amount (USD) subtracted after the percentage.' },
446
465
  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.' },
447
466
  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.' },
448
467
  offer_ids: { type: 'array', items: { type: 'number' } },
@@ -476,6 +495,10 @@ const TOOLS = [
476
495
  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.' },
477
496
  conversion_pixel_id: { type: ['number', 'null'], description: 'Id of a tracking pixel to fire on conversion, or null to clear.' },
478
497
  conversion_event: { type: 'string', description: 'Event the conversion pixel fires (must be valid for that pixel\'s platform).' },
498
+ 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).' },
499
+ report_value_fixed: { type: ['number', 'null'], description: 'Fixed value (USD) reported on every conversion when report_value_mode = "fixed". Null to clear.' },
500
+ report_value_adjust_pct: { type: ['number', 'null'], description: 'Percentage (0–100) subtracted from real payout when report_value_mode = "adjusted". Null to clear.' },
501
+ report_value_adjust_flat: { type: ['number', 'null'], description: 'Flat USD amount subtracted (after the %) when report_value_mode = "adjusted". Null to clear.' },
479
502
  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.' },
480
503
  fallback_url: { type: ['string', 'null'], description: 'Never-dead-end fallback URL when no live offer exists, or null to use the platform default.' },
481
504
  archived: { type: 'boolean' },
@@ -488,6 +511,11 @@ const TOOLS = [
488
511
  description: 'Delete a campaign. The short link stops resolving immediately. Tracker events for this campaign are also deleted (cascade).',
489
512
  inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
490
513
  },
514
+ {
515
+ name: 'duplicate_campaign',
516
+ 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.',
517
+ inputSchema: { type: 'object', properties: { campaign_id: { type: 'number' } }, required: ['campaign_id'] },
518
+ },
491
519
  {
492
520
  name: 'set_campaign_offers',
493
521
  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.',
@@ -672,6 +700,12 @@ const TOOLS = [
672
700
  '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.',
673
701
  inputSchema: { type: 'object', properties: {} },
674
702
  },
703
+ {
704
+ name: 'list_observed_campaign_tags',
705
+ description:
706
+ '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, events, utm_source, utm_medium, lander_url, claimed_by }` where `claimed_by` is `{ id, name }` of the BGL campaign whose `direct_slug` already owns that tag, 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 set it as a campaign\'s `direct_slug` (via `update_campaign`) so BGL attributes that traffic — and its `/click` CTA — to the right campaign + offer. Skip tags whose `claimed_by` is non-null (another campaign already owns them).',
707
+ inputSchema: { type: 'object', properties: {} },
708
+ },
675
709
  {
676
710
  name: 'get_tracker_overview',
677
711
  description:
@@ -735,6 +769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
735
769
  }
736
770
  case 'update_link': {
737
771
  const body = {};
772
+ if (typeof args.destination_url === 'string') body.destination_url = args.destination_url;
738
773
  if (typeof args.title === 'string') body.title = args.title;
739
774
  if (typeof args.source_label === 'string') body.source_label = args.source_label;
740
775
  if (typeof args.archived === 'boolean') body.archived = args.archived;
@@ -751,6 +786,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
751
786
  });
752
787
  break;
753
788
  }
789
+ case 'refresh_link_image': {
790
+ result = await api(`/api/links/${Number(args.link_id)}/refresh-image`, {
791
+ method: 'POST',
792
+ });
793
+ break;
794
+ }
754
795
  case 'list_links': {
755
796
  const qs = args?.include_archived ? '?include_archived=1' : '';
756
797
  result = await api(`/api/links${qs}`);
@@ -796,6 +837,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
796
837
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
797
838
  if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
798
839
  if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
840
+ if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
799
841
  result = await api('/api/affiliate-networks', { method: 'POST', body: JSON.stringify(body) });
800
842
  break;
801
843
  }
@@ -805,6 +847,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
805
847
  if (typeof args.conversion_param === 'string') body.conversion_param = args.conversion_param;
806
848
  if (typeof args.payout_param === 'string') body.payout_param = args.payout_param;
807
849
  if (typeof args.currency_param === 'string') body.currency_param = args.currency_param;
850
+ if (typeof args.txid_param === 'string') body.txid_param = args.txid_param;
808
851
  if (typeof args.archived === 'boolean') body.archived = args.archived;
809
852
  result = await api(`/api/affiliate-networks/${Number(args.network_id)}`, { method: 'PATCH', body: JSON.stringify(body) });
810
853
  break;
@@ -939,6 +982,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
939
982
  if ('domain_id' in args) body.domain_id = args.domain_id;
940
983
  if ('traffic_source_id' in args) body.traffic_source_id = args.traffic_source_id;
941
984
  if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
985
+ if (typeof args.report_value_mode === 'string') body.report_value_mode = args.report_value_mode;
986
+ if ('report_value_fixed' in args) body.report_value_fixed = args.report_value_fixed;
987
+ if ('report_value_adjust_pct' in args) body.report_value_adjust_pct = args.report_value_adjust_pct;
988
+ if ('report_value_adjust_flat' in args) body.report_value_adjust_flat = args.report_value_adjust_flat;
942
989
  if ('direct_slug' in args) body.direct_slug = args.direct_slug;
943
990
  if ('fallback_url' in args) body.fallback_url = args.fallback_url;
944
991
  if (Array.isArray(args.offer_ids)) body.offer_ids = args.offer_ids;
@@ -962,6 +1009,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
962
1009
  if (typeof args.cost_value === 'number') body.cost_value = args.cost_value;
963
1010
  if ('domain_id' in args) body.domain_id = args.domain_id;
964
1011
  if ('conversion_pixel_id' in args) body.conversion_pixel_id = args.conversion_pixel_id;
1012
+ if (typeof args.report_value_mode === 'string') body.report_value_mode = args.report_value_mode;
1013
+ if ('report_value_fixed' in args) body.report_value_fixed = args.report_value_fixed;
1014
+ if ('report_value_adjust_pct' in args) body.report_value_adjust_pct = args.report_value_adjust_pct;
1015
+ if ('report_value_adjust_flat' in args) body.report_value_adjust_flat = args.report_value_adjust_flat;
965
1016
  if ('direct_slug' in args) body.direct_slug = args.direct_slug;
966
1017
  if ('fallback_url' in args) body.fallback_url = args.fallback_url;
967
1018
  if (typeof args.archived === 'boolean') body.archived = args.archived;
@@ -972,6 +1023,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
972
1023
  result = await api(`/api/campaigns/${Number(args.campaign_id)}`, { method: 'DELETE' });
973
1024
  break;
974
1025
  }
1026
+ case 'duplicate_campaign': {
1027
+ result = await api(`/api/campaigns/${Number(args.campaign_id)}/duplicate`, { method: 'POST' });
1028
+ break;
1029
+ }
975
1030
  case 'set_campaign_offers': {
976
1031
  result = await api(`/api/campaigns/${Number(args.campaign_id)}/offers`, {
977
1032
  method: 'PUT', body: JSON.stringify({ items: args.items }),
@@ -1043,6 +1098,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1043
1098
  result = await api('/api/property-pages');
1044
1099
  break;
1045
1100
  }
1101
+ case 'list_observed_campaign_tags': {
1102
+ result = await api('/api/observed-campaign-tags');
1103
+ break;
1104
+ }
1046
1105
 
1047
1106
  case 'get_tracker_overview': {
1048
1107
  const days = args?.days ? `?days=${Number(args.days)}` : '';