@affset/mcp 0.2.0 → 0.4.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 +114 -54
- package/dist/lib/bids.d.ts +21 -0
- package/dist/lib/bids.js +84 -0
- 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 +60 -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/listSourceBids.d.ts +12 -0
- package/dist/tools/listSourceBids.js +51 -0
- 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/setSourceBid.d.ts +22 -0
- package/dist/tools/setSourceBid.js +195 -0
- 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 +99 -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,57 @@ 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
|
+
| `list_source_bids` | The network campaigns of one traffic source with their **current bids**, read live from the network account (ExoClick, TrafficStars, RichAds — needs the source's API token): status, pricing model, bid in USD, last change that entered Affset's mutation stage. Read-only. |
|
|
43
|
+
| `set_source_bid` | Set one network campaign's bid in USD using its existing pricing model (for RichAds: CPM for pops, CPC for push/display). The dry run returns `expected_current_bid`; pass that value back with `confirm: true` to bind the write to the bid reviewed, so a concurrent change is refused rather than overwritten. Records the attempt in the source's bid history and reports applied only when the network echoes the new value. Raising a bid above 5× needs `allow_large_increase: true`. **Dry-run by default**. |
|
|
44
|
+
| `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. |
|
|
45
|
+
| `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. |
|
|
46
|
+
| `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
|
|
47
|
+
| `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
|
|
48
|
+
| `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
|
|
49
|
+
| `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
|
|
50
|
+
| `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
|
|
51
|
+
| `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
|
|
52
|
+
| `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
|
|
53
|
+
| `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
|
|
54
|
+
| `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
|
|
55
|
+
| `list_sub_labels` | List tenant display names for sub1–sub5. |
|
|
56
|
+
| `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
|
|
57
|
+
| `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
58
|
|
|
41
59
|
### Which URL do I give the network?
|
|
42
60
|
|
|
@@ -88,7 +106,8 @@ under `AFFSET_READ_ONLY`.
|
|
|
88
106
|
|
|
89
107
|
## Configuration
|
|
90
108
|
|
|
91
|
-
|
|
109
|
+
Self-host (stdio) only — hosted connections do not use these variables. All
|
|
110
|
+
config comes from the environment (never hard-coded):
|
|
92
111
|
|
|
93
112
|
| Variable | Description |
|
|
94
113
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -103,9 +122,28 @@ See [`.env.example`](.env.example).
|
|
|
103
122
|
|
|
104
123
|
## Install
|
|
105
124
|
|
|
106
|
-
|
|
125
|
+
### Hosted (fastest — no install)
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
Add the remote server in your MCP client and approve access in the browser.
|
|
128
|
+
OAuth is discovered from the endpoint — do not paste an API key, and do not add
|
|
129
|
+
an `Authorization` header.
|
|
130
|
+
|
|
131
|
+
- **Claude (web or desktop)** — Customize → Connectors → **+** → Add custom connector →
|
|
132
|
+
`https://mcp.affset.com/mcp`.
|
|
133
|
+
- **Cursor** — Settings → MCP → Add server, transport "streamable HTTP", same URL.
|
|
134
|
+
- **Claude Code** — `claude mcp add --transport http affset https://mcp.affset.com/mcp`
|
|
135
|
+
then authenticate with `/mcp`.
|
|
136
|
+
|
|
137
|
+
You sign in with your affset email (magic link). **Read-only** is selected on
|
|
138
|
+
the consent screen unless you switch to **Full access**. The connection gets
|
|
139
|
+
its own scoped credential — your API key is never involved — and appears on
|
|
140
|
+
the dashboard's Integrations page, where it can be revoked at any time. Full
|
|
141
|
+
guide: [affset.com/integrations](https://affset.com/integrations).
|
|
142
|
+
|
|
143
|
+
The self-host paths below run the same tool roster over stdio and require
|
|
144
|
+
Node.js 22.13 or newer.
|
|
145
|
+
|
|
146
|
+
### From npm (recommended for self-hosting)
|
|
109
147
|
|
|
110
148
|
No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
|
|
111
149
|
(`claude_desktop_config.json`):
|
|
@@ -203,12 +241,15 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
|
|
|
203
241
|
>
|
|
204
242
|
> **stats for one publisher, grouped by zone** → `get_stats(group_by: "zone_id", publisher_email: "publisher@example.com")`
|
|
205
243
|
>
|
|
206
|
-
> **
|
|
244
|
+
> **stats including informative conversions** → `get_stats(paid_only: false)`
|
|
245
|
+
>
|
|
246
|
+
> **set up RichAds end to end** → `create_traffic_source(name: "RichAds", preset: "richads")` (dry-run) → confirm
|
|
247
|
+
> → `create_zone(name: "RichAds push", traffic_source_id: "…")` (dry-run) → confirm → `get_zone_url()`
|
|
207
248
|
>
|
|
208
249
|
> **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
|
|
209
250
|
> → `create_campaign(user_email: "buyer@example.com", offer_url: "https://offer.example/lp?s={click_id}", geo: ["BR"], payout: 2)` (dry-run) → confirm
|
|
210
251
|
>
|
|
211
|
-
> **what URL do I paste into RichAds?** → `get_zone_url(
|
|
252
|
+
> **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
253
|
>
|
|
213
254
|
> **give me the link for campaign 42 again** → `get_tracking_link(campaign_id: 42)`
|
|
214
255
|
>
|
|
@@ -235,7 +276,9 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
|
|
|
235
276
|
>
|
|
236
277
|
> **show recent conversions** → `list_conversions()`
|
|
237
278
|
>
|
|
238
|
-
> **
|
|
279
|
+
> **hide informative conversions (goal-type misses)** → `list_conversions(paid_only: true)`
|
|
280
|
+
>
|
|
281
|
+
> **find $0 payouts (no rule / still on this page)** → `list_conversions(zero_payout: true)`
|
|
239
282
|
>
|
|
240
283
|
> **lookup by source click id** → `list_conversions(source_click_id: "abc123")`
|
|
241
284
|
|
|
@@ -243,11 +286,16 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
|
|
|
243
286
|
|
|
244
287
|
- **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
|
|
245
288
|
calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` /
|
|
246
|
-
`conversion_type` / `advertiser_email` / `publisher_email` filters. The two email
|
|
289
|
+
`conversion_type` / `advertiser_email` / `publisher_email` / `paid_only` filters. The two email
|
|
247
290
|
filters select one user's campaigns or zones without changing `group_by`; access
|
|
248
291
|
is limited to owner/manager or the corresponding scoped manager role. Filtering
|
|
249
292
|
by `conversion_type` returns conversion rows only (impressions, clicks and media
|
|
250
|
-
cost are zero).
|
|
293
|
+
cost are zero). `paid_only` defaults to **true** (the API default is false; this
|
|
294
|
+
matches the dashboard) so the conversions count and CR drop informative rows
|
|
295
|
+
recorded with `postback_skipped=non_goal_type` — pixel type missed the campaign's
|
|
296
|
+
`payout_goal_type`. Silent conversions still count; this is not a payout>0 filter.
|
|
297
|
+
Set `false` for the raw count. Only recent (unfolded) events are filtered;
|
|
298
|
+
conversions already in daily archives stay included.
|
|
251
299
|
- **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
|
|
252
300
|
cost data imported for the slice.
|
|
253
301
|
- List endpoints have **no server-side name search** — `name_contains` filters the
|
|
@@ -277,9 +325,13 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
|
|
|
277
325
|
while the campaign kept buying); `list_targeting_types` flags them. Use
|
|
278
326
|
`unique_users` (`visits/hours`) for frequency capping.
|
|
279
327
|
- **`list_conversions`** is the conversion audit trail (not aggregated stats). The API
|
|
280
|
-
has no campaign/zone/date filters;
|
|
281
|
-
|
|
282
|
-
|
|
328
|
+
has no campaign/zone/date filters; `paid_only` is the one server-side filter (`true`
|
|
329
|
+
drops rows recorded with `postback_skipped=non_goal_type` — pixel type missed the
|
|
330
|
+
campaign's `payout_goal_type`; silent conversions and other skip reasons still come
|
|
331
|
+
back — this is not a payout>0 filter). The other optional filters apply to the current
|
|
332
|
+
page only. Rows do not include campaign_id/zone_id. Publisher-side roles do not see
|
|
333
|
+
`spend` and advertiser-side roles do not see `payout`, so `zero_payout` needs a role
|
|
334
|
+
that can; `paid_only` does not (it keys on `postback_skipped`, not `payout`).
|
|
283
335
|
- **`create_team_member`** creates the API key directly (like the dashboard's "Add Team
|
|
284
336
|
Member") — it does not send an invite email. Hand the returned key to the person
|
|
285
337
|
yourself. Revoking/removing a team member is not yet a tool; use the dashboard's
|
|
@@ -298,7 +350,7 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
|
|
|
298
350
|
Since 0.2.0 the package doubles as a runtime-agnostic library: everything the
|
|
299
351
|
stdio server registers (tools, docs resources, read-only stripping) is exposed
|
|
300
352
|
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
|
|
353
|
+
Workers. The hosted affset MCP gateway (`mcp.affset.com`) consumes
|
|
302
354
|
exactly this surface, so the remote roster can never drift from stdio.
|
|
303
355
|
|
|
304
356
|
```ts
|
|
@@ -345,17 +397,23 @@ npm run dev # watch mode
|
|
|
345
397
|
|
|
346
398
|
## Security
|
|
347
399
|
|
|
348
|
-
-
|
|
400
|
+
- **Hosted (`https://mcp.affset.com/mcp`):** OAuth via magic link. Read-only is
|
|
401
|
+
the consent default (mutating tools are never registered). Full access still
|
|
402
|
+
dry-runs mutations until `confirm: true`. Revoke from the dashboard
|
|
403
|
+
Integrations page. Nothing on `mcp.affset.com` / `oauth.affset.com` asks for
|
|
404
|
+
an API key.
|
|
405
|
+
- **Self-host (stdio):** no secrets in the repo; credentials come from the
|
|
406
|
+
environment at runtime. Create a **dedicated, least-privilege, expiring API
|
|
407
|
+
key** rather than reusing an owner key.
|
|
349
408
|
- `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
|
|
350
409
|
- Tenant API responses are streamed under a 5 MB hard limit; larger bodies are
|
|
351
410
|
cancelled before parsing or reaching model context.
|
|
352
411
|
- stdout is the JSON-RPC channel — all logs go to stderr.
|
|
353
412
|
- `list_team` redacts API tokens.
|
|
354
413
|
- All mutations (including creates) follow **show → confirm → apply**.
|
|
355
|
-
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
tool calls exactly as they do to the dashboard.
|
|
414
|
+
- affset's RBAC roles (owner / manager / publisher / advertiser /
|
|
415
|
+
advertiser_manager / publisher_manager) apply to MCP tool calls exactly as
|
|
416
|
+
they do to the dashboard.
|
|
359
417
|
- Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
|
|
360
418
|
floating `main` spec can run newer repository code the next time `npx` resolves it.
|
|
361
419
|
|
|
@@ -375,10 +433,12 @@ Mitigations in place:
|
|
|
375
433
|
- `confirm: true` on mutating tools is a **model-level** safety net, not a security
|
|
376
434
|
boundary — a model that has been steered by injected content can supply
|
|
377
435
|
`confirm: true` itself. The only real boundary is your MCP client's per-call tool
|
|
378
|
-
approval
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
436
|
+
approval plus **read-only mode** (hosted: consent default; stdio:
|
|
437
|
+
**`AFFSET_READ_ONLY=true`**).
|
|
438
|
+
|
|
439
|
+
Prefer read-only for any session where you're mainly reading stats/conversions,
|
|
440
|
+
especially with an MCP client that auto-approves tool calls. It removes every
|
|
441
|
+
mutation tool from the server entirely — not hidden behind a prompt, unavailable
|
|
442
|
+
to call. Reserve full access (hosted) or a read-write stdio instance for sessions
|
|
443
|
+
where you're actively managing campaigns/zones/payouts and are reviewing each
|
|
444
|
+
confirm yourself.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SourceBidCampaign, SourceBidChange } from "../types.js";
|
|
2
|
+
/** The precision accepted by the source-bid API. */
|
|
3
|
+
export declare const BID_DECIMALS = 6;
|
|
4
|
+
/** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
|
|
5
|
+
export declare const LARGE_INCREASE_FACTOR = 5;
|
|
6
|
+
/** Network campaign names come from the network account — third-party text in model context. */
|
|
7
|
+
export declare const NETWORK_NAME_MAX = 120;
|
|
8
|
+
export declare function pricingModelLabel(model: string): string;
|
|
9
|
+
/** Preserve every decimal place that can be sent to the network. */
|
|
10
|
+
export declare function formatBid(bid: number | null | undefined): string;
|
|
11
|
+
/** Network-supplied error text that can land in the model's context. */
|
|
12
|
+
export declare function networkErrorText(message: string): string;
|
|
13
|
+
/** Match the API's optimistic-concurrency comparison, including unknown bids. */
|
|
14
|
+
export declare function bidsMatch(left: number | null, right: number | null): boolean;
|
|
15
|
+
/** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
|
|
16
|
+
export declare function describeBidChange(previous: number | null | undefined, next: number): string;
|
|
17
|
+
export declare function isLargeIncrease(previous: number | null | undefined, next: number): boolean;
|
|
18
|
+
/** One-line rendering of a change row for tables and confirmations. */
|
|
19
|
+
export declare function describeChangeRow(change: SourceBidChange): string;
|
|
20
|
+
/** Markdown table of a source's network campaigns with their live bids. */
|
|
21
|
+
export declare function formatBidsTable(campaigns: SourceBidCampaign[]): string;
|
package/dist/lib/bids.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { capUntrusted, mdCell } from "./format.js";
|
|
2
|
+
/** The precision accepted by the source-bid API. */
|
|
3
|
+
export const BID_DECIMALS = 6;
|
|
4
|
+
/** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
|
|
5
|
+
export const LARGE_INCREASE_FACTOR = 5;
|
|
6
|
+
/** Network campaign names come from the network account — third-party text in model context. */
|
|
7
|
+
export const NETWORK_NAME_MAX = 120;
|
|
8
|
+
const NETWORK_TOKEN_MAX = 40;
|
|
9
|
+
const MODEL_LABEL = {
|
|
10
|
+
cpc: "CPC",
|
|
11
|
+
cpm: "CPM",
|
|
12
|
+
cpa: "CPA",
|
|
13
|
+
cpv: "CPV",
|
|
14
|
+
smart_cpm: "Smart CPM",
|
|
15
|
+
smart_cpc: "Smart CPC",
|
|
16
|
+
smart_bid: "Smart bid",
|
|
17
|
+
};
|
|
18
|
+
export function pricingModelLabel(model) {
|
|
19
|
+
if (!model)
|
|
20
|
+
return "—";
|
|
21
|
+
return MODEL_LABEL[model] ?? mdCell(capUntrusted(model, NETWORK_TOKEN_MAX));
|
|
22
|
+
}
|
|
23
|
+
/** Preserve every decimal place that can be sent to the network. */
|
|
24
|
+
export function formatBid(bid) {
|
|
25
|
+
if (bid == null || !Number.isFinite(bid))
|
|
26
|
+
return "—";
|
|
27
|
+
return `$${bid.toLocaleString("en-US", {
|
|
28
|
+
minimumFractionDigits: 2,
|
|
29
|
+
maximumFractionDigits: BID_DECIMALS,
|
|
30
|
+
})}`;
|
|
31
|
+
}
|
|
32
|
+
/** Network-supplied error text that can land in the model's context. */
|
|
33
|
+
export function networkErrorText(message) {
|
|
34
|
+
return mdCell(capUntrusted(message));
|
|
35
|
+
}
|
|
36
|
+
function roundBid(bid) {
|
|
37
|
+
const scale = 10 ** BID_DECIMALS;
|
|
38
|
+
return Math.round(bid * scale) / scale;
|
|
39
|
+
}
|
|
40
|
+
/** Match the API's optimistic-concurrency comparison, including unknown bids. */
|
|
41
|
+
export function bidsMatch(left, right) {
|
|
42
|
+
return left === null || right === null ? left === right : roundBid(left) === roundBid(right);
|
|
43
|
+
}
|
|
44
|
+
/** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
|
|
45
|
+
export function describeBidChange(previous, next) {
|
|
46
|
+
return `${formatBid(previous)} → ${formatBid(next)}`;
|
|
47
|
+
}
|
|
48
|
+
export function isLargeIncrease(previous, next) {
|
|
49
|
+
return previous != null && previous > 0 && next > roundBid(previous * LARGE_INCREASE_FACTOR);
|
|
50
|
+
}
|
|
51
|
+
/** One-line rendering of a change row for tables and confirmations. */
|
|
52
|
+
export function describeChangeRow(change) {
|
|
53
|
+
const when = new Date(change.created_at).toISOString().replace("T", " ").slice(0, 16);
|
|
54
|
+
const who = change.changed_by ? ` by ${mdCell(change.changed_by)}` : "";
|
|
55
|
+
const outcome = change.status === "applied"
|
|
56
|
+
? "applied"
|
|
57
|
+
: change.status === "pending"
|
|
58
|
+
? `outcome pending${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`
|
|
59
|
+
: `failed${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`;
|
|
60
|
+
return `${when} UTC · ${describeBidChange(change.previous_bid, change.bid)}${who} · ${outcome}`;
|
|
61
|
+
}
|
|
62
|
+
/** Markdown table of a source's network campaigns with their live bids. */
|
|
63
|
+
export function formatBidsTable(campaigns) {
|
|
64
|
+
if (campaigns.length === 0)
|
|
65
|
+
return "_The network account has no campaigns._";
|
|
66
|
+
const lines = [
|
|
67
|
+
"| Campaign | Network id | Status | Model | Bid | Last change via affset |",
|
|
68
|
+
"|---|---|---|---|--:|---|",
|
|
69
|
+
];
|
|
70
|
+
for (const campaign of campaigns) {
|
|
71
|
+
const name = campaign.name ? mdCell(capUntrusted(campaign.name, NETWORK_NAME_MAX)) : "—";
|
|
72
|
+
const status = campaign.status === "other" && campaign.status_label
|
|
73
|
+
? mdCell(capUntrusted(campaign.status_label, NETWORK_TOKEN_MAX))
|
|
74
|
+
: campaign.status;
|
|
75
|
+
const extra = Object.entries(campaign.extra)
|
|
76
|
+
.map(([key, value]) => `${mdCell(capUntrusted(key, NETWORK_TOKEN_MAX))} ${formatBid(value)}`)
|
|
77
|
+
.join(", ");
|
|
78
|
+
const bid = campaign.bid === null ? "—" : `${formatBid(campaign.bid)}${extra ? ` (${extra})` : ""}`;
|
|
79
|
+
const last = campaign.last_change ? describeChangeRow(campaign.last_change) : "—";
|
|
80
|
+
lines.push(`| ${name} | \`${mdCell(campaign.network_campaign_id)}\` | ${status} | ${pricingModelLabel(campaign.pricing_model)} | ${bid} | ${last} |`);
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=bids.js.map
|
|
@@ -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;
|