@affset/mcp 0.2.0 → 0.3.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
@@ -4,39 +4,55 @@ An [MCP](https://modelcontextprotocol.io) server that lets a media buyer run aff
4
4
  from a chat client — pull stats, manage campaigns/zones/team, payouts, targeting,
5
5
  sub labels, and cut underperforming zones in plain language, no dashboard.
6
6
 
7
- It's a thin wrapper over the existing affset tenant API (`Bearer` token +
8
- `X-Namespace`). One server instance serves one tenant.
7
+ The tools wrap the existing affset tenant API. Connect through the hosted
8
+ endpoint (OAuth, no API key) or run this package locally (`Bearer` token +
9
+ `X-Namespace`). One connection serves one tenant.
10
+
11
+ **Fastest way to connect — the hosted endpoint.** Add
12
+ `https://mcp.affset.com/mcp` as a remote MCP server in Claude (web or desktop),
13
+ Cursor, Claude Code, or any client that supports streamable HTTP with OAuth:
14
+ paste the URL, sign in with your affset email, keep **Read-only** (the consent
15
+ default) or grant full access. Every connection shows up on the dashboard
16
+ Integrations page and can be revoked individually. Setup guide:
17
+ [affset.com/integrations](https://affset.com/integrations).
18
+
19
+ The npm package below is the self-host path: same tool roster, runs on your
20
+ machine with an API key you manage yourself. Stdio defaults to full access
21
+ unless you set `AFFSET_READ_ONLY=true`.
9
22
 
10
23
  ## Tools
11
24
 
12
- | Tool | What it does |
13
- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
- | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. |
15
- | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, advertiser, publisher, …), optionally narrowed with `advertiser_email`/`publisher_email` filters (one user, any group_by). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. Per-user groupings and filters are owner/manager plus the matching side's manager role. |
16
- | `list_campaigns` | List campaigns (status / name filter, pagination). |
17
- | `get_campaign` | One campaign's full record — every field (untruncated offer URL, exact schedule, budgets/pacing, silent flag, payout goal type) plus its targeting rules and payout rules, in one call. |
18
- | `list_zones` | List traffic-source zones (status / name filter, pagination). |
19
- | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
20
- | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
21
- | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. |
22
- | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
23
- | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
24
- | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
25
- | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
26
- | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. |
27
- | `get_tracking_link` | The `/track/click` link for an existing campaign + zone straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. |
28
- | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
29
- | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
30
- | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
31
- | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
32
- | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
33
- | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
34
- | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
35
- | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
36
- | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
37
- | `list_sub_labels` | List tenant display names for sub1–sub5. |
38
- | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
39
- | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. |
25
+ | Tool | What it does |
26
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. |
28
+ | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, advertiser, publisher, …), optionally narrowed with `advertiser_email`/`publisher_email` filters (one user, any group_by). Returns clicks, conversions, CR, payout, media cost and ROI as a table. `paid_only` defaults to true (same as the dashboard) so CR excludes informative conversions. Sub columns use the tenant's sub labels when configured. Per-user groupings and filters are owner/manager plus the matching side's manager role. |
29
+ | `list_campaigns` | List campaigns (status / name filter, pagination). |
30
+ | `get_campaign` | One campaign's full record — every field (untruncated offer URL, exact schedule, budgets/pacing, silent flag, payout goal type) plus its targeting rules and payout rules, in one call. |
31
+ | `list_zones` | List traffic-source zones (status / name filter, pagination, linked source). |
32
+ | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
33
+ | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
34
+ | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, and a ready tracking link (linked source template when configured; otherwise `source_click_id={clickid}` + sub placeholders). **Dry-run by default**; `confirm: true` to apply. |
35
+ | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
36
+ | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
37
+ | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs, optional `traffic_source_id` link). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
38
+ | `update_zone` | Partial update (name, status, URLs, `traffic_source_id`). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL or unlink the source. |
39
+ | `list_traffic_sources` | List traffic sources the networks bought from, each with the tracking/postback templates its linked zones use. API token shown only as set/none. |
40
+ | `create_traffic_source` | Create a traffic source, optionally from a network preset (`exoclick`, `trafficstars`, `propellerads`, `adsterra`, `richads`) that copies verified templates into an editable row. **Dry-run by default**; `confirm: true` to apply. |
41
+ | `update_traffic_source` | Partial update (name, templates, api_token, status). Linked zones pick the new tracking template up immediately. **Dry-run by default**; `confirm: true` to apply. |
42
+ | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. A zone linked to a traffic source renders that source's tracking template; otherwise prefilled sub convention + optional `cost` macro. Warns when no active campaigns are visible. |
43
+ | `get_tracking_link` | The `/track/click` link for an existing campaign + zone — straight to one active campaign, with no rotation or targeting checks. Renders a linked source's template like `get_zone_url`. Re-derives what `create_campaign` echoed on create. |
44
+ | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
45
+ | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
46
+ | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
47
+ | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
48
+ | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
49
+ | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
50
+ | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
51
+ | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
52
+ | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
53
+ | `list_sub_labels` | List tenant display names for sub1–sub5. |
54
+ | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
55
+ | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). `paid_only` filters server-side; other optional filters are client-side on the current page. |
40
56
 
41
57
  ### Which URL do I give the network?
42
58
 
@@ -88,7 +104,8 @@ under `AFFSET_READ_ONLY`.
88
104
 
89
105
  ## Configuration
90
106
 
91
- All config comes from environment variables (never hard-coded):
107
+ Self-host (stdio) only hosted connections do not use these variables. All
108
+ config comes from the environment (never hard-coded):
92
109
 
93
110
  | Variable | Description |
94
111
  | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -103,9 +120,28 @@ See [`.env.example`](.env.example).
103
120
 
104
121
  ## Install
105
122
 
106
- Requires Node.js 22.13 or newer.
123
+ ### Hosted (fastest no install)
107
124
 
108
- ### From npm (recommended)
125
+ Add the remote server in your MCP client and approve access in the browser.
126
+ OAuth is discovered from the endpoint — do not paste an API key, and do not add
127
+ an `Authorization` header.
128
+
129
+ - **Claude (web or desktop)** — Customize → Connectors → **+** → Add custom connector →
130
+ `https://mcp.affset.com/mcp`.
131
+ - **Cursor** — Settings → MCP → Add server, transport "streamable HTTP", same URL.
132
+ - **Claude Code** — `claude mcp add --transport http affset https://mcp.affset.com/mcp`
133
+ then authenticate with `/mcp`.
134
+
135
+ You sign in with your affset email (magic link). **Read-only** is selected on
136
+ the consent screen unless you switch to **Full access**. The connection gets
137
+ its own scoped credential — your API key is never involved — and appears on
138
+ the dashboard's Integrations page, where it can be revoked at any time. Full
139
+ guide: [affset.com/integrations](https://affset.com/integrations).
140
+
141
+ The self-host paths below run the same tool roster over stdio and require
142
+ Node.js 22.13 or newer.
143
+
144
+ ### From npm (recommended for self-hosting)
109
145
 
110
146
  No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
111
147
  (`claude_desktop_config.json`):
@@ -203,12 +239,15 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
203
239
  >
204
240
  > **stats for one publisher, grouped by zone** → `get_stats(group_by: "zone_id", publisher_email: "publisher@example.com")`
205
241
  >
206
- > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
242
+ > **stats including informative conversions** → `get_stats(paid_only: false)`
243
+ >
244
+ > **set up RichAds end to end** → `create_traffic_source(name: "RichAds", preset: "richads")` (dry-run) → confirm
245
+ > → `create_zone(name: "RichAds push", traffic_source_id: "…")` (dry-run) → confirm → `get_zone_url()`
207
246
  >
208
247
  > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
209
248
  > → `create_campaign(user_email: "buyer@example.com", offer_url: "https://offer.example/lp?s={click_id}", geo: ["BR"], payout: 2)` (dry-run) → confirm
210
249
  >
211
- > **what URL do I paste into RichAds?** → `get_zone_url(cost: "{cost}")`
250
+ > **what URL do I paste into RichAds?** → `get_zone_url()` — a zone linked to a traffic source gets the source's template filled in
212
251
  >
213
252
  > **give me the link for campaign 42 again** → `get_tracking_link(campaign_id: 42)`
214
253
  >
@@ -235,7 +274,9 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
235
274
  >
236
275
  > **show recent conversions** → `list_conversions()`
237
276
  >
238
- > **find $0 payouts (goal miss?)** → `list_conversions(zero_payout: true)`
277
+ > **hide informative conversions (goal-type misses)** → `list_conversions(paid_only: true)`
278
+ >
279
+ > **find $0 payouts (no rule / still on this page)** → `list_conversions(zero_payout: true)`
239
280
  >
240
281
  > **lookup by source click id** → `list_conversions(source_click_id: "abc123")`
241
282
 
@@ -243,11 +284,16 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
243
284
 
244
285
  - **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
245
286
  calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` /
246
- `conversion_type` / `advertiser_email` / `publisher_email` filters. The two email
287
+ `conversion_type` / `advertiser_email` / `publisher_email` / `paid_only` filters. The two email
247
288
  filters select one user's campaigns or zones without changing `group_by`; access
248
289
  is limited to owner/manager or the corresponding scoped manager role. Filtering
249
290
  by `conversion_type` returns conversion rows only (impressions, clicks and media
250
- cost are zero).
291
+ cost are zero). `paid_only` defaults to **true** (the API default is false; this
292
+ matches the dashboard) so the conversions count and CR drop informative rows
293
+ recorded with `postback_skipped=non_goal_type` — pixel type missed the campaign's
294
+ `payout_goal_type`. Silent conversions still count; this is not a payout>0 filter.
295
+ Set `false` for the raw count. Only recent (unfolded) events are filtered;
296
+ conversions already in daily archives stay included.
251
297
  - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
252
298
  cost data imported for the slice.
253
299
  - List endpoints have **no server-side name search** — `name_contains` filters the
@@ -277,9 +323,13 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
277
323
  while the campaign kept buying); `list_targeting_types` flags them. Use
278
324
  `unique_users` (`visits/hours`) for frequency capping.
279
325
  - **`list_conversions`** is the conversion audit trail (not aggregated stats). The API
280
- has no campaign/zone/date filters; optional filters apply to the current page only.
281
- Rows do not include campaign_id/zone_id. Publisher-side roles do not see `spend` and
282
- advertiser-side roles do not see `payout`, so `zero_payout` needs a role that can.
326
+ has no campaign/zone/date filters; `paid_only` is the one server-side filter (`true`
327
+ drops rows recorded with `postback_skipped=non_goal_type` pixel type missed the
328
+ campaign's `payout_goal_type`; silent conversions and other skip reasons still come
329
+ back — this is not a payout>0 filter). The other optional filters apply to the current
330
+ page only. Rows do not include campaign_id/zone_id. Publisher-side roles do not see
331
+ `spend` and advertiser-side roles do not see `payout`, so `zero_payout` needs a role
332
+ that can; `paid_only` does not (it keys on `postback_skipped`, not `payout`).
283
333
  - **`create_team_member`** creates the API key directly (like the dashboard's "Add Team
284
334
  Member") — it does not send an invite email. Hand the returned key to the person
285
335
  yourself. Revoking/removing a team member is not yet a tool; use the dashboard's
@@ -298,7 +348,7 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
298
348
  Since 0.2.0 the package doubles as a runtime-agnostic library: everything the
299
349
  stdio server registers (tools, docs resources, read-only stripping) is exposed
300
350
  as one helper that runs on any fetch-capable runtime — Node ≥22.13 or Cloudflare
301
- Workers. The hosted affset MCP gateway (`mcp.affset.com`, in progress) consumes
351
+ Workers. The hosted affset MCP gateway (`mcp.affset.com`) consumes
302
352
  exactly this surface, so the remote roster can never drift from stdio.
303
353
 
304
354
  ```ts
@@ -345,17 +395,23 @@ npm run dev # watch mode
345
395
 
346
396
  ## Security
347
397
 
348
- - No secrets in the repo; credentials come from the environment at runtime.
398
+ - **Hosted (`https://mcp.affset.com/mcp`):** OAuth via magic link. Read-only is
399
+ the consent default (mutating tools are never registered). Full access still
400
+ dry-runs mutations until `confirm: true`. Revoke from the dashboard
401
+ Integrations page. Nothing on `mcp.affset.com` / `oauth.affset.com` asks for
402
+ an API key.
403
+ - **Self-host (stdio):** no secrets in the repo; credentials come from the
404
+ environment at runtime. Create a **dedicated, least-privilege, expiring API
405
+ key** rather than reusing an owner key.
349
406
  - `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
350
407
  - Tenant API responses are streamed under a 5 MB hard limit; larger bodies are
351
408
  cancelled before parsing or reaching model context.
352
409
  - stdout is the JSON-RPC channel — all logs go to stderr.
353
410
  - `list_team` redacts API tokens.
354
411
  - All mutations (including creates) follow **show → confirm → apply**.
355
- - Create a **dedicated, least-privilege API key** for the MCP rather than reusing an
356
- owner key, and give it an expiry affset's RBAC roles (owner / manager /
357
- publisher / advertiser / advertiser_manager / publisher_manager) apply to MCP
358
- tool calls exactly as they do to the dashboard.
412
+ - affset's RBAC roles (owner / manager / publisher / advertiser /
413
+ advertiser_manager / publisher_manager) apply to MCP tool calls exactly as
414
+ they do to the dashboard.
359
415
  - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
360
416
  floating `main` spec can run newer repository code the next time `npx` resolves it.
361
417
 
@@ -375,10 +431,12 @@ Mitigations in place:
375
431
  - `confirm: true` on mutating tools is a **model-level** safety net, not a security
376
432
  boundary — a model that has been steered by injected content can supply
377
433
  `confirm: true` itself. The only real boundary is your MCP client's per-call tool
378
- approval and **`AFFSET_READ_ONLY`**.
379
-
380
- **Set `AFFSET_READ_ONLY=true`** for any session where you're mainly reading stats/
381
- conversions, especially with an MCP client that auto-approves tool calls. It removes
382
- every mutation tool from the server entirely not hidden behind a prompt, unavailable
383
- to call. Reserve a read-write instance (or a separate one) for sessions where you're
384
- actively managing campaigns/zones/payouts and are reviewing each confirm yourself.
434
+ approval plus **read-only mode** (hosted: consent default; stdio:
435
+ **`AFFSET_READ_ONLY=true`**).
436
+
437
+ Prefer read-only for any session where you're mainly reading stats/conversions,
438
+ especially with an MCP client that auto-approves tool calls. It removes every
439
+ mutation tool from the server entirely not hidden behind a prompt, unavailable
440
+ to call. Reserve full access (hosted) or a read-write stdio instance for sessions
441
+ where you're actively managing campaigns/zones/payouts and are reviewing each
442
+ confirm yourself.
@@ -12,12 +12,13 @@
12
12
  */
13
13
  import type { AffsetClient } from "../client.js";
14
14
  import type { Config } from "../runtimeConfig.js";
15
- import { type SubKey, type SubLabels } from "../types.js";
15
+ import { type SubKey, type SubLabels, type Zone } from "../types.js";
16
16
  /** Query parameter carrying the traffic source's click token. */
17
17
  export declare const SOURCE_CLICK_ID_PARAM = "source_click_id";
18
18
  /**
19
- * Default click-token macro. RichAds' spelling the pilot source; other networks
20
- * substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
19
+ * Generic click-token placeholder for zones with no linked traffic source the
20
+ * buyer swaps in their network's macro (`[CLICK_ID]`, `${SUBID}`, …). A linked
21
+ * source replaces the whole query with its own tracking template instead.
21
22
  */
22
23
  export declare const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
23
24
  export type SubValues = Partial<Record<SubKey, string>>;
@@ -39,19 +40,48 @@ export declare function fetchTenantIntegration(client: AffsetClient, config: Con
39
40
  /** `custom_api_domain` is tenant-editable free text — only use it if it parses. */
40
41
  export declare function integrationBaseUrl(customApiDomain: string | null | undefined, fallback: string): string;
41
42
  export interface LinkParams {
42
- /** Click-token macro. Omit for the RichAds default; pass "" to leave it out. */
43
+ /** Click-token macro. Omit for the generic default; pass "" to leave it out. */
43
44
  sourceClickId?: string;
44
45
  /** Explicit sub values; unset slots fall back to a label-derived placeholder. */
45
46
  subs?: SubValues;
46
47
  /** Network cost macro for `?cost=`. Omitted when absent. */
47
48
  cost?: string;
48
49
  subLabels?: SubLabels;
50
+ /**
51
+ * A linked traffic source's tracking template: the entire query string, used
52
+ * verbatim so network macro syntaxes (`{x}`, `${X}`, `##X##`, `[X]`) survive
53
+ * byte-exact. When set and non-empty, every per-field param above is ignored.
54
+ */
55
+ template?: string;
49
56
  }
50
57
  /** `/serve/{zone_id}` — the zone URL, for campaign rotation. */
51
58
  export declare function buildZoneUrl(baseUrl: string, zoneId: string, params?: LinkParams): string;
52
59
  /** `/track/click/{campaign_id}/{zone_id}` — straight to one campaign. */
53
60
  export declare function buildTrackingLink(baseUrl: string, campaignId: number | string, zoneId: string, params?: LinkParams): string;
61
+ /**
62
+ * True only when the template contains the exact query-parameter name.
63
+ * Leading `?`/`&` and per-part whitespace are ignored so a stored template
64
+ * like `" source_click_id={x}"` still counts (create/update trim, but rows
65
+ * written from the dashboard may not).
66
+ */
67
+ export declare function templateHasParam(template: string | undefined, param: string): boolean;
54
68
  /** "Creative name" -> `creative_name`; falls back to the raw sub key. */
55
69
  export declare function placeholderName(key: string, label: string | undefined): string;
56
70
  /** "sub1 = Creative, sub2 = Placement" — so the buyer can see what belongs where. */
57
71
  export declare function subLegend(subLabels: SubLabels, subs?: SubValues): string | null;
72
+ /** What the URL tools need to render a linked source's template — or explain why not. */
73
+ export interface LinkedSourceContext {
74
+ /** Query string for the URL builder, verbatim; undefined → generic assembly. */
75
+ template?: string;
76
+ /** "Traffic source" table-row value; undefined when the zone is unlinked. */
77
+ sourceLabel?: string;
78
+ /** Warnings and suggestions to append under the URL table. */
79
+ notes: string[];
80
+ }
81
+ /**
82
+ * Resolve a zone's linked traffic source into URL-building context (PRD
83
+ * traffic-sources §4.3). Explicit link params always win over the stored
84
+ * template — that override is deliberate — and a linked-but-unreadable source
85
+ * degrades to the generic assembly with a warning instead of failing the tool.
86
+ */
87
+ export declare function resolveLinkedSource(client: AffsetClient, zone: Pick<Zone, "traffic_source_id" | "traffic_source_name" | "postback_url">, hasManualParams: boolean): Promise<LinkedSourceContext>;
@@ -10,13 +10,14 @@
10
10
  * Both carry the same query convention: `source_click_id` (the network's own click
11
11
  * token, echoed back on postback) plus the five analytics sub slots.
12
12
  */
13
- import { SUB_KEYS } from "../types.js";
13
+ import { SUB_KEYS, } from "../types.js";
14
14
  import { mdCell } from "./format.js";
15
15
  /** Query parameter carrying the traffic source's click token. */
16
16
  export const SOURCE_CLICK_ID_PARAM = "source_click_id";
17
17
  /**
18
- * Default click-token macro. RichAds' spelling the pilot source; other networks
19
- * substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
18
+ * Generic click-token placeholder for zones with no linked traffic source the
19
+ * buyer swaps in their network's macro (`[CLICK_ID]`, `${SUBID}`, …). A linked
20
+ * source replaces the whole query with its own tracking template instead.
20
21
  */
21
22
  export const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
22
23
  /**
@@ -89,7 +90,11 @@ export function buildTrackingLink(baseUrl, campaignId, zoneId, params = {}) {
89
90
  * read as the primary parameter in the copied URL. All five sub slots are emitted as
90
91
  * a template for the buyer to fill in or delete.
91
92
  */
92
- function withQuery(base, { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, cost, subLabels = {} }) {
93
+ function withQuery(base, params) {
94
+ const template = params.template;
95
+ if (template?.trim())
96
+ return `${base}?${template}`;
97
+ const { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, cost, subLabels = {} } = params;
93
98
  const parts = [];
94
99
  const clickId = sourceClickId.trim();
95
100
  if (clickId)
@@ -103,6 +108,16 @@ function withQuery(base, { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, c
103
108
  parts.push(`cost=${costMacro}`);
104
109
  return parts.length > 0 ? `${base}?${parts.join("&")}` : base;
105
110
  }
111
+ /**
112
+ * True only when the template contains the exact query-parameter name.
113
+ * Leading `?`/`&` and per-part whitespace are ignored so a stored template
114
+ * like `" source_click_id={x}"` still counts (create/update trim, but rows
115
+ * written from the dashboard may not).
116
+ */
117
+ export function templateHasParam(template, param) {
118
+ const query = (template ?? "").trim().replace(/^[?&]+/, "");
119
+ return query.split("&").some((part) => part.trim().startsWith(`${param}=`));
120
+ }
106
121
  /** "Creative name" -> `creative_name`; falls back to the raw sub key. */
107
122
  export function placeholderName(key, label) {
108
123
  const slug = (label ?? "")
@@ -125,4 +140,69 @@ export function subLegend(subLabels, subs = {}) {
125
140
  });
126
141
  return labelled.length > 0 ? labelled.join(" · ") : null;
127
142
  }
143
+ /**
144
+ * Resolve a zone's linked traffic source into URL-building context (PRD
145
+ * traffic-sources §4.3). Explicit link params always win over the stored
146
+ * template — that override is deliberate — and a linked-but-unreadable source
147
+ * degrades to the generic assembly with a warning instead of failing the tool.
148
+ */
149
+ export async function resolveLinkedSource(client, zone, hasManualParams) {
150
+ const notes = [];
151
+ const linkedId = zone.traffic_source_id?.trim();
152
+ if (!linkedId)
153
+ return { notes };
154
+ let source = null;
155
+ try {
156
+ source = await client.get(`/api/traffic-sources/${encodeURIComponent(linkedId)}`);
157
+ }
158
+ catch {
159
+ source = null;
160
+ }
161
+ if (!source) {
162
+ const name = mdCell(zone.traffic_source_name ?? linkedId);
163
+ return {
164
+ sourceLabel: name,
165
+ notes: [
166
+ `⚠️ This zone links to traffic source **${name}**, but it could not be read — ` +
167
+ "showing generic placeholders instead of its tracking template.",
168
+ ],
169
+ };
170
+ }
171
+ const sourceLabel = source.preset
172
+ ? `${mdCell(source.name)} (preset \`${mdCell(source.preset)}\`)`
173
+ : mdCell(source.name);
174
+ if (source.status === "archived") {
175
+ notes.push(`The linked source **${sourceLabel}** is archived — its template still renders, ` +
176
+ "but consider relinking the zone if this account is no longer bought from.");
177
+ }
178
+ // Prefill suggestion only — never auto-applied: [BRACKETED] account-specific
179
+ // values have to be filled in by the buyer first.
180
+ if (!zone.postback_url && source.postback_template) {
181
+ notes.push(source.postback_template.includes("`")
182
+ ? `The linked source has a postback template for this zone's postback_url, but it contains backticks — read it with \`list_traffic_sources\`.`
183
+ : `The linked source suggests a postback URL for this zone — replace \`[BRACKETED]\` account values, then set it with \`update_zone\`: \`${source.postback_template}\``);
184
+ }
185
+ if (hasManualParams) {
186
+ notes.push(`Explicit link parameters override the tracking template of **${sourceLabel}**.`);
187
+ return { sourceLabel, notes };
188
+ }
189
+ const template = source.tracking_template ?? "";
190
+ if (!template.trim()) {
191
+ notes.push(`Traffic source **${sourceLabel}** has no tracking template — showing generic ` +
192
+ "placeholders. Add one with `update_traffic_source`.");
193
+ return { sourceLabel, notes };
194
+ }
195
+ // A backtick cannot be rendered into the fenced URL block without letting the
196
+ // stored template break out of it; no network macro needs one.
197
+ if (template.includes("`")) {
198
+ notes.push(`⚠️ The tracking template of **${sourceLabel}** contains a backtick and was not ` +
199
+ "rendered — fix the template with `update_traffic_source`.");
200
+ return { sourceLabel, notes };
201
+ }
202
+ if (!templateHasParam(template, SOURCE_CLICK_ID_PARAM)) {
203
+ notes.push(`⚠️ The source's tracking template has no \`${SOURCE_CLICK_ID_PARAM}=\` parameter — ` +
204
+ "the network's click token is not captured, so postbacks cannot echo it back.");
205
+ }
206
+ return { template, sourceLabel, notes };
207
+ }
128
208
  //# sourceMappingURL=integrationUrls.js.map
@@ -25,3 +25,8 @@ export type LinkArgs = {
25
25
  };
26
26
  /** Pull the flat sub1..sub5 args into the record the URL builders take. */
27
27
  export declare function collectSubs(args: LinkArgs): SubValues;
28
+ /**
29
+ * True when the caller passed any link knob explicitly. That is the deliberate
30
+ * override that beats a linked traffic source's stored tracking template.
31
+ */
32
+ export declare function hasExplicitLinkParams(args: LinkArgs): boolean;
@@ -27,9 +27,11 @@ const subSchema = (key) => linkValue(key)
27
27
  export const linkInputSchema = {
28
28
  source_click_id: linkValue("source_click_id")
29
29
  .optional()
30
- .describe("The traffic source's click-token macro, e.g. `{clickid}` (RichAds), `[CLICK_ID]`, " +
31
- `\`\${SUBID}\`. Default \`${DEFAULT_SOURCE_CLICK_ID}\`. This is what conversion ` +
32
- 'postbacks echo back via {source_click_id} — pass "" only to omit it deliberately.'),
30
+ .describe("The traffic source's click-token macro, e.g. `[CLICK_ID]` (RichAds), " +
31
+ `\`\${SUBID}\` (PropellerAds). Default \`${DEFAULT_SOURCE_CLICK_ID}\`. This is what ` +
32
+ 'conversion postbacks echo back via {source_click_id} — pass "" only to omit it ' +
33
+ "deliberately. Zones linked to a traffic source render the source's tracking " +
34
+ "template instead; passing any explicit link parameter overrides that template."),
33
35
  cost: linkValue("cost")
34
36
  .optional()
35
37
  .describe("The network's cost macro, e.g. `{cost}`. Adds `&cost=…` so media cost is imported " +
@@ -50,4 +52,13 @@ export function collectSubs(args) {
50
52
  }
51
53
  return subs;
52
54
  }
55
+ /**
56
+ * True when the caller passed any link knob explicitly. That is the deliberate
57
+ * override that beats a linked traffic source's stored tracking template.
58
+ */
59
+ export function hasExplicitLinkParams(args) {
60
+ return (args.source_click_id !== undefined ||
61
+ args.cost !== undefined ||
62
+ SUB_KEYS.some((key) => args[key] !== undefined));
63
+ }
53
64
  //# sourceMappingURL=linkArgs.js.map
package/dist/lib/zones.js CHANGED
@@ -60,7 +60,15 @@ async function listActiveZones(client) {
60
60
  return { zones, total: res.pagination?.total ?? zones.length };
61
61
  }
62
62
  function zoneList(zones, total) {
63
- const lines = zones.map((z) => `- \`${z.id}\` — ${mdCell(z.name)}${z.postback_url ? "" : " (no postback URL)"}`);
63
+ const lines = zones.map((z) => {
64
+ const source = z.traffic_source_name
65
+ ? ` · ${mdCell(z.traffic_source_name)}`
66
+ : z.traffic_source_id
67
+ ? ` · \`${mdCell(z.traffic_source_id)}\``
68
+ : "";
69
+ const postback = z.postback_url ? "" : " (no postback URL)";
70
+ return `- \`${z.id}\` — ${mdCell(z.name)}${source}${postback}`;
71
+ });
64
72
  if (total > zones.length) {
65
73
  lines.push(`- …and ${total - zones.length} more`);
66
74
  }
@@ -13,6 +13,9 @@ import { createZone, createZoneInputSchema, CREATE_ZONE_DESCRIPTION } from "./to
13
13
  import { getZoneUrl, getZoneUrlInputSchema, GET_ZONE_URL_DESCRIPTION } from "./tools/getZoneUrl.js";
14
14
  import { getTrackingLink, getTrackingLinkInputSchema, GET_TRACKING_LINK_DESCRIPTION, } from "./tools/getTrackingLink.js";
15
15
  import { updateZone, updateZoneInputSchema, UPDATE_ZONE_DESCRIPTION } from "./tools/updateZone.js";
16
+ import { listTrafficSources, listTrafficSourcesInputSchema, LIST_TRAFFIC_SOURCES_DESCRIPTION, } from "./tools/listTrafficSources.js";
17
+ import { createTrafficSource, createTrafficSourceInputSchema, CREATE_TRAFFIC_SOURCE_DESCRIPTION, } from "./tools/createTrafficSource.js";
18
+ import { updateTrafficSource, updateTrafficSourceInputSchema, UPDATE_TRAFFIC_SOURCE_DESCRIPTION, } from "./tools/updateTrafficSource.js";
16
19
  import { updateCampaign, updateCampaignInputSchema, UPDATE_CAMPAIGN_DESCRIPTION, } from "./tools/updateCampaign.js";
17
20
  import { setCampaignStatus, setCampaignStatusInputSchema, SET_CAMPAIGN_STATUS_DESCRIPTION, } from "./tools/setCampaignStatus.js";
18
21
  import { listPayoutRules, listPayoutRulesInputSchema, LIST_PAYOUT_RULES_DESCRIPTION, } from "./tools/listPayoutRules.js";
@@ -246,6 +249,39 @@ export function registerAffsetTools(toolServer, config, options = {}) {
246
249
  openWorldHint: true,
247
250
  },
248
251
  }, (args) => updateZone(client, args));
252
+ registerTool("list_traffic_sources", {
253
+ title: "List traffic sources",
254
+ description: LIST_TRAFFIC_SOURCES_DESCRIPTION,
255
+ inputSchema: listTrafficSourcesInputSchema,
256
+ annotations: {
257
+ readOnlyHint: true,
258
+ destructiveHint: false,
259
+ idempotentHint: true,
260
+ openWorldHint: true,
261
+ },
262
+ }, (args) => listTrafficSources(client, args));
263
+ registerTool("create_traffic_source", {
264
+ title: "Create a traffic source",
265
+ description: CREATE_TRAFFIC_SOURCE_DESCRIPTION,
266
+ inputSchema: createTrafficSourceInputSchema,
267
+ annotations: {
268
+ readOnlyHint: false,
269
+ destructiveHint: false,
270
+ idempotentHint: false,
271
+ openWorldHint: true,
272
+ },
273
+ }, (args) => createTrafficSource(client, args));
274
+ registerTool("update_traffic_source", {
275
+ title: "Update a traffic source",
276
+ description: UPDATE_TRAFFIC_SOURCE_DESCRIPTION,
277
+ inputSchema: updateTrafficSourceInputSchema,
278
+ annotations: {
279
+ readOnlyHint: false,
280
+ destructiveHint: true,
281
+ idempotentHint: false,
282
+ openWorldHint: true,
283
+ },
284
+ }, (args) => updateTrafficSource(client, args));
249
285
  registerTool("cut_zones", {
250
286
  title: "Cut underperforming zones",
251
287
  description: CUT_ZONES_DESCRIPTION,