@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 +112 -54
- package/dist/lib/integrationUrls.d.ts +34 -4
- package/dist/lib/integrationUrls.js +84 -4
- package/dist/lib/linkArgs.d.ts +5 -0
- package/dist/lib/linkArgs.js +14 -3
- package/dist/lib/zones.js +9 -1
- package/dist/registerTools.js +36 -0
- package/dist/tools/createCampaign.js +20 -8
- package/dist/tools/createTrafficSource.d.ts +25 -0
- package/dist/tools/createTrafficSource.js +191 -0
- package/dist/tools/createZone.d.ts +2 -0
- package/dist/tools/createZone.js +13 -1
- package/dist/tools/getStats.d.ts +2 -0
- package/dist/tools/getStats.js +19 -3
- package/dist/tools/getTrackingLink.js +26 -9
- package/dist/tools/getZoneUrl.js +30 -13
- package/dist/tools/listConversions.d.ts +2 -0
- package/dist/tools/listConversions.js +29 -10
- package/dist/tools/listTrafficSources.d.ts +17 -0
- package/dist/tools/listTrafficSources.js +63 -0
- package/dist/tools/listZones.js +10 -5
- package/dist/tools/updateTrafficSource.d.ts +25 -0
- package/dist/tools/updateTrafficSource.js +171 -0
- package/dist/tools/updateZone.d.ts +2 -0
- package/dist/tools/updateZone.js +13 -3
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
### Hosted (fastest — no install)
|
|
107
124
|
|
|
108
|
-
|
|
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
|
-
> **
|
|
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(
|
|
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
|
-
> **
|
|
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;
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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,
|
|
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
|
package/dist/lib/linkArgs.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/linkArgs.js
CHANGED
|
@@ -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. `
|
|
31
|
-
`\`\${SUBID}
|
|
32
|
-
'postbacks echo back via {source_click_id} — pass "" only to omit it
|
|
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) =>
|
|
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
|
}
|
package/dist/registerTools.js
CHANGED
|
@@ -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,
|