@affset/mcp 0.1.1 → 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.
Files changed (79) hide show
  1. package/.env.example +5 -0
  2. package/README.md +191 -56
  3. package/dist/client.d.ts +43 -0
  4. package/dist/client.js +15 -2
  5. package/dist/config.d.ts +7 -0
  6. package/dist/config.js +15 -54
  7. package/dist/core.d.ts +18 -0
  8. package/dist/core.js +18 -0
  9. package/dist/docs.d.ts +39 -0
  10. package/dist/docs.js +105 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/lib/format.d.ts +46 -0
  13. package/dist/lib/format.js +3 -0
  14. package/dist/lib/integrationUrls.d.ts +87 -0
  15. package/dist/lib/integrationUrls.js +84 -4
  16. package/dist/lib/linkArgs.d.ts +32 -0
  17. package/dist/lib/linkArgs.js +14 -3
  18. package/dist/lib/patch.d.ts +10 -0
  19. package/dist/lib/payoutRules.d.ts +38 -0
  20. package/dist/lib/readBody.d.ts +17 -0
  21. package/dist/lib/readBody.js +69 -0
  22. package/dist/lib/targeting.d.ts +51 -0
  23. package/dist/lib/time.d.ts +38 -0
  24. package/dist/lib/toolResult.d.ts +7 -0
  25. package/dist/lib/urls.d.ts +2 -0
  26. package/dist/lib/zones.d.ts +24 -0
  27. package/dist/lib/zones.js +9 -1
  28. package/dist/registerTools.d.ts +40 -0
  29. package/dist/registerTools.js +437 -0
  30. package/dist/runtimeConfig.d.ts +37 -0
  31. package/dist/runtimeConfig.js +93 -0
  32. package/dist/server.d.ts +4 -0
  33. package/dist/server.js +3 -318
  34. package/dist/tools/createCampaign.d.ts +25 -0
  35. package/dist/tools/createCampaign.js +20 -8
  36. package/dist/tools/createTeamMember.d.ts +26 -0
  37. package/dist/tools/createTrafficSource.d.ts +25 -0
  38. package/dist/tools/createTrafficSource.js +191 -0
  39. package/dist/tools/createZone.d.ts +24 -0
  40. package/dist/tools/createZone.js +13 -1
  41. package/dist/tools/cutZones.d.ts +29 -0
  42. package/dist/tools/deletePayoutRule.d.ts +16 -0
  43. package/dist/tools/getCampaign.d.ts +12 -0
  44. package/dist/tools/getCampaign.js +150 -0
  45. package/dist/tools/getStats.d.ts +42 -0
  46. package/dist/tools/getStats.js +54 -3
  47. package/dist/tools/getTrackingLink.d.ts +23 -0
  48. package/dist/tools/getTrackingLink.js +26 -9
  49. package/dist/tools/getZoneUrl.d.ts +21 -0
  50. package/dist/tools/getZoneUrl.js +30 -13
  51. package/dist/tools/listCampaigns.d.ts +23 -0
  52. package/dist/tools/listConversions.d.ts +31 -0
  53. package/dist/tools/listConversions.js +29 -10
  54. package/dist/tools/listPayoutRules.d.ts +12 -0
  55. package/dist/tools/listSubLabels.d.ts +5 -0
  56. package/dist/tools/listTargetingRules.d.ts +12 -0
  57. package/dist/tools/listTargetingTypes.d.ts +5 -0
  58. package/dist/tools/listTeam.d.ts +14 -0
  59. package/dist/tools/listTrafficSources.d.ts +17 -0
  60. package/dist/tools/listTrafficSources.js +63 -0
  61. package/dist/tools/listZones.d.ts +23 -0
  62. package/dist/tools/listZones.js +10 -5
  63. package/dist/tools/removeTargetingRule.d.ts +21 -0
  64. package/dist/tools/setCampaignStatus.d.ts +18 -0
  65. package/dist/tools/setPayoutGoal.d.ts +16 -0
  66. package/dist/tools/setPayoutRule.d.ts +18 -0
  67. package/dist/tools/setSubLabels.d.ts +22 -0
  68. package/dist/tools/setTargetingRule.d.ts +21 -0
  69. package/dist/tools/updateCampaign.d.ts +35 -0
  70. package/dist/tools/updateTrafficSource.d.ts +25 -0
  71. package/dist/tools/updateTrafficSource.js +171 -0
  72. package/dist/tools/updateZone.d.ts +27 -0
  73. package/dist/tools/updateZone.js +13 -3
  74. package/dist/tools/whoami.d.ts +6 -0
  75. package/dist/types.d.ts +257 -0
  76. package/dist/types.js +2 -0
  77. package/dist/version.d.ts +7 -0
  78. package/dist/version.js +8 -0
  79. package/package.json +15 -2
package/.env.example CHANGED
@@ -12,6 +12,11 @@ AFFSET_NAMESPACE=your-namespace
12
12
  # Optional. Per-request HTTP timeout in milliseconds (1000–300000; default 30000).
13
13
  # AFFSET_REQUEST_TIMEOUT_MS=30000
14
14
 
15
+ # Optional. Origin the API-reference documentation resources are fetched from
16
+ # (origin only, no path). Defaults to the affset marketing site; override to
17
+ # point at a staging docs host. Fetched anonymously — no API key is sent here.
18
+ # AFFSET_DOCS_URL=https://affset.com
19
+
15
20
  # Optional. Set to true to register only read-only tools (no create/update/delete/
16
21
  # cut) — recommended for stats/reporting sessions, especially with an MCP client
17
22
  # that auto-approves tool calls. See README Security section. Default false.
package/README.md CHANGED
@@ -4,38 +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, …). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. |
16
- | `list_campaigns` | List campaigns (status / name filter, pagination). |
17
- | `list_zones` | List traffic-source zones (status / name filter, pagination). |
18
- | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
19
- | `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. |
20
- | `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. |
21
- | `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. |
22
- | `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. |
23
- | `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. |
24
- | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
25
- | `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. |
26
- | `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. |
27
- | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
28
- | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
29
- | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
30
- | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
31
- | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
32
- | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
33
- | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
34
- | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
35
- | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
36
- | `list_sub_labels` | List tenant display names for sub1–sub5. |
37
- | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
38
- | `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. |
39
56
 
40
57
  ### Which URL do I give the network?
41
58
 
@@ -64,23 +81,67 @@ URL. Both URL types also require an active zone. Geo whitelist is enforced in `/
64
81
  only — the direct tracking link is **not** geo-gated, but it still requires an active,
65
82
  currently serviceable campaign.
66
83
 
84
+ ## Documentation resources
85
+
86
+ Beyond the tools, the server exposes the affset **API reference** as MCP
87
+ [resources](https://modelcontextprotocol.io/docs/concepts/resources), so an
88
+ assistant can answer "how does conversion tracking work?" or "what does `/serve`
89
+ accept?" from the docs themselves — not just from the tool schemas.
90
+
91
+ | Resource URI | Type | Content |
92
+ | ---------------------------------- | ------------------ | ------------------------------------------------------------ |
93
+ | `affset://docs/api-reference` | `text/markdown` | The full API reference — endpoints, auth, roles, examples. |
94
+ | `affset://docs/api-reference.json` | `application/json` | The same reference as structured data, for programmatic use. |
95
+
96
+ They're the exact content published at [affset.com/docs](https://affset.com/docs),
97
+ generated from one source, and **fetched at read time** from `AFFSET_DOCS_URL`
98
+ (`{origin}/api-reference.md` and `{origin}/api-reference.json`) — so they always
99
+ reflect the currently published docs, not a copy pinned to this package. The
100
+ fetch sends **no credentials** (the docs are public and live on a different
101
+ origin than the tenant API). HTML SPA fallbacks, redirects, invalid JSON, and
102
+ oversized bodies are rejected. Both resources are always available, including
103
+ under `AFFSET_READ_ONLY`.
104
+
67
105
  ## Configuration
68
106
 
69
- 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):
70
109
 
71
- | Variable | Description |
72
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
- | `AFFSET_BASE_URL` | Origin of the affset API, e.g. `https://api.affset.com` (no path/query/credentials). Must be `https` unless the host is `localhost`/`127.0.0.1`/`::1` — plain http would send the API key in cleartext. |
74
- | `AFFSET_API_KEY` | Tenant API key. Its namespace must match `AFFSET_NAMESPACE`. |
75
- | `AFFSET_NAMESPACE` | Tenant namespace (lowercase letters, numbers, hyphens; 3–63 chars — same rules as signup). |
76
- | `AFFSET_READ_ONLY` | Optional, default `false`. Set to `true`/`1` to register only the read-only tools (`whoami`, `get_stats`, every `list_*`, `get_zone_url`, `get_tracking_link`) — every create/update/delete/cut tool is unavailable, not just gated behind confirm. See [Security](#security) for why this matters. |
77
- | `AFFSET_REQUEST_TIMEOUT_MS` | Optional, default `30000`. Per-request HTTP timeout in milliseconds (`1000`–`300000`). |
110
+ | Variable | Description |
111
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
+ | `AFFSET_BASE_URL` | Origin of the affset API, e.g. `https://api.affset.com` (no path/query/credentials). Must be `https` unless the host is `localhost`/`127.0.0.1`/`::1` — plain http would send the API key in cleartext. |
113
+ | `AFFSET_API_KEY` | Tenant API key. Its namespace must match `AFFSET_NAMESPACE`. |
114
+ | `AFFSET_NAMESPACE` | Tenant namespace (lowercase letters, numbers, hyphens; 3–63 chars — same rules as signup). |
115
+ | `AFFSET_READ_ONLY` | Optional, default `false`. Set to `true`/`1` to register only the read-only tools (`whoami`, `get_stats`, `get_campaign`, every `list_*`, `get_zone_url`, `get_tracking_link`) — every create/update/delete/cut tool is unavailable, not just gated behind confirm. See [Security](#security) for why this matters. |
116
+ | `AFFSET_REQUEST_TIMEOUT_MS` | Optional, default `30000`. Per-request HTTP timeout in milliseconds (`1000`–`300000`). |
117
+ | `AFFSET_DOCS_URL` | Optional, default `https://affset.com`. Origin the API-reference [documentation resources](#documentation-resources) are fetched from (origin only, no path). Fetched anonymously — no API key is sent here. |
78
118
 
79
119
  See [`.env.example`](.env.example).
80
120
 
81
121
  ## Install
82
122
 
83
- ### From npm (recommended)
123
+ ### Hosted (fastest — no install)
124
+
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)
84
145
 
85
146
  No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
86
147
  (`claude_desktop_config.json`):
@@ -164,6 +225,8 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
164
225
 
165
226
  > **list paused campaigns** → `list_campaigns(status: "paused")`
166
227
  >
228
+ > **show me everything about campaign 42** → `get_campaign(campaign_id: 42)`
229
+ >
167
230
  > **show zones** → `list_zones()`
168
231
  >
169
232
  > **who's on the team?** → `list_team()`
@@ -172,12 +235,19 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
172
235
  >
173
236
  > **stats for today by sub1** → `get_stats(group_by: "sub1")`
174
237
  >
175
- > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
238
+ > **stats by advertiser** → `get_stats(group_by: "advertiser_email")`
239
+ >
240
+ > **stats for one publisher, grouped by zone** → `get_stats(group_by: "zone_id", publisher_email: "publisher@example.com")`
241
+ >
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()`
176
246
  >
177
247
  > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
178
248
  > → `create_campaign(user_email: "buyer@example.com", offer_url: "https://offer.example/lp?s={click_id}", geo: ["BR"], payout: 2)` (dry-run) → confirm
179
249
  >
180
- > **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
181
251
  >
182
252
  > **give me the link for campaign 42 again** → `get_tracking_link(campaign_id: 42)`
183
253
  >
@@ -204,14 +274,26 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
204
274
  >
205
275
  > **show recent conversions** → `list_conversions()`
206
276
  >
207
- > **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)`
208
280
  >
209
281
  > **lookup by source click id** → `list_conversions(source_click_id: "abc123")`
210
282
 
211
283
  ## Notes & limits
212
284
 
213
285
  - **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
214
- calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` filters.
286
+ calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` /
287
+ `conversion_type` / `advertiser_email` / `publisher_email` / `paid_only` filters. The two email
288
+ filters select one user's campaigns or zones without changing `group_by`; access
289
+ is limited to owner/manager or the corresponding scoped manager role. Filtering
290
+ by `conversion_type` returns conversion rows only (impressions, clicks and media
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.
215
297
  - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
216
298
  cost data imported for the slice.
217
299
  - List endpoints have **no server-side name search** — `name_contains` filters the
@@ -241,9 +323,13 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
241
323
  while the campaign kept buying); `list_targeting_types` flags them. Use
242
324
  `unique_users` (`visits/hours`) for frequency capping.
243
325
  - **`list_conversions`** is the conversion audit trail (not aggregated stats). The API
244
- has no campaign/zone/date filters; optional filters apply to the current page only.
245
- Rows do not include campaign_id/zone_id. Publisher-side roles do not see `spend` and
246
- 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`).
247
333
  - **`create_team_member`** creates the API key directly (like the dashboard's "Add Team
248
334
  Member") — it does not send an invite email. Hand the returned key to the person
249
335
  yourself. Revoking/removing a team member is not yet a tool; use the dashboard's
@@ -257,6 +343,44 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
257
343
  startup — so it could not use a tenant it just created. Sign up in the dashboard,
258
344
  then point a server instance at the new namespace.
259
345
 
346
+ ## Using as a library
347
+
348
+ Since 0.2.0 the package doubles as a runtime-agnostic library: everything the
349
+ stdio server registers (tools, docs resources, read-only stripping) is exposed
350
+ as one helper that runs on any fetch-capable runtime — Node ≥22.13 or Cloudflare
351
+ Workers. The hosted affset MCP gateway (`mcp.affset.com`) consumes
352
+ exactly this surface, so the remote roster can never drift from stdio.
353
+
354
+ ```ts
355
+ import { registerAffsetTools, type Config } from "@affset/mcp/core";
356
+
357
+ const config: Config = {
358
+ baseUrl: "https://api.affset.com",
359
+ docsBaseUrl: "https://affset.com",
360
+ apiKey: perRequestKey, // e.g. an OAuth grant's backing credential
361
+ namespace: tenantNamespace,
362
+ requestTimeoutMs: 30_000,
363
+ readOnly: scope === "read", // never registers tools without readOnlyHint: true
364
+ };
365
+
366
+ registerAffsetTools(server, config); // server: your own McpServer instance
367
+ ```
368
+
369
+ `registerAffsetTools` accepts your `McpServer` structurally, so your own
370
+ `@modelcontextprotocol/sdk` install works — no need to match this package's
371
+ copy. Env-var loading (`AFFSET_*`) is deliberately not part of the library
372
+ surface; it belongs to the stdio entrypoint only. A third, optional
373
+ `{ onToolCall }` argument reports only tool name, duration, and success/error
374
+ status for transport-owned audit logging; arguments and output are never
375
+ included.
376
+
377
+ The library validates and normalizes `config` before registering anything.
378
+ Remote API origins must use HTTPS (plain HTTP is accepted only on loopback),
379
+ origins cannot contain credentials or paths, and invalid namespaces, timeouts,
380
+ API keys, or non-boolean read-only settings fail closed at startup. The public
381
+ declarations do not require Node ambient types, so the same import type-checks
382
+ in Workers and other web-standard runtimes.
383
+
260
384
  ## Development
261
385
 
262
386
  ```bash
@@ -271,14 +395,23 @@ npm run dev # watch mode
271
395
 
272
396
  ## Security
273
397
 
274
- - 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.
275
406
  - `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
407
+ - Tenant API responses are streamed under a 5 MB hard limit; larger bodies are
408
+ cancelled before parsing or reaching model context.
276
409
  - stdout is the JSON-RPC channel — all logs go to stderr.
277
410
  - `list_team` redacts API tokens.
278
411
  - All mutations (including creates) follow **show → confirm → apply**.
279
- - Create a **dedicated, least-privilege API key** for the MCP rather than reusing an
280
- owner key, and give it an expiry affset's RBAC roles (owner/manager/publisher/
281
- advertiser) apply to MCP 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.
282
415
  - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
283
416
  floating `main` spec can run newer repository code the next time `npx` resolves it.
284
417
 
@@ -298,10 +431,12 @@ Mitigations in place:
298
431
  - `confirm: true` on mutating tools is a **model-level** safety net, not a security
299
432
  boundary — a model that has been steered by injected content can supply
300
433
  `confirm: true` itself. The only real boundary is your MCP client's per-call tool
301
- approval and **`AFFSET_READ_ONLY`**.
302
-
303
- **Set `AFFSET_READ_ONLY=true`** for any session where you're mainly reading stats/
304
- conversions, especially with an MCP client that auto-approves tool calls. It removes
305
- every mutation tool from the server entirely not hidden behind a prompt, unavailable
306
- to call. Reserve a read-write instance (or a separate one) for sessions where you're
307
- 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.
@@ -0,0 +1,43 @@
1
+ import { type Config } from "./runtimeConfig.js";
2
+ /** Thrown when the affset API returns a non-2xx response. */
3
+ export declare class AffsetApiError extends Error {
4
+ readonly status: number;
5
+ /** Parsed JSON body when present (e.g. plan-limit details). */
6
+ readonly body?: unknown | undefined;
7
+ constructor(status: number, message: string,
8
+ /** Parsed JSON body when present (e.g. plan-limit details). */
9
+ body?: unknown | undefined);
10
+ }
11
+ type Query = Record<string, string | number | undefined>;
12
+ /**
13
+ * Thin typed HTTP client over the affset tenant API. Injects the Bearer token
14
+ * and `X-Namespace` header on every request and normalises error handling.
15
+ */
16
+ export declare class AffsetClient {
17
+ private readonly config;
18
+ constructor(config: Config);
19
+ /** Resolved once per process; the tenant timezone changes about never. */
20
+ private tenantTimezone?;
21
+ /**
22
+ * The tenant's timezone (IANA name). Date ranges must be resolved in it, not
23
+ * in the host machine's zone, so that a requested window lines up with the
24
+ * date buckets `/api/stats?group_by=date` returns.
25
+ *
26
+ * Falls back to UTC if the setting cannot be read — a stats query with a
27
+ * slightly-off window beats failing the whole call.
28
+ */
29
+ getTenantTimezone(): Promise<string>;
30
+ /**
31
+ * Tenant timezone for a write whose meaning depends on local midnight. Unlike
32
+ * stats reads, silently falling back to UTC here could change a schedule, so
33
+ * callers get the underlying API error and must stop the mutation.
34
+ */
35
+ getRequiredTenantTimezone(): Promise<string>;
36
+ private readTenantTimezone;
37
+ get<T>(path: string, query?: Query): Promise<T>;
38
+ post<T>(path: string, body: unknown): Promise<T>;
39
+ put<T>(path: string, body: unknown): Promise<T>;
40
+ delete<T>(path: string, query?: Query): Promise<T>;
41
+ private request;
42
+ }
43
+ export {};
package/dist/client.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readResponseText, ResponseTooLargeError } from "./lib/readBody.js";
2
+ import { normalizeRuntimeConfig } from "./runtimeConfig.js";
1
3
  /** Thrown when the affset API returns a non-2xx response. */
2
4
  export class AffsetApiError extends Error {
3
5
  status;
@@ -11,6 +13,8 @@ export class AffsetApiError extends Error {
11
13
  this.name = "AffsetApiError";
12
14
  }
13
15
  }
16
+ /** Bound upstream memory use before JSON parsing or rendering into model context. */
17
+ const MAX_API_RESPONSE_BYTES = 5_000_000;
14
18
  /**
15
19
  * Thin typed HTTP client over the affset tenant API. Injects the Bearer token
16
20
  * and `X-Namespace` header on every request and normalises error handling.
@@ -18,7 +22,7 @@ export class AffsetApiError extends Error {
18
22
  export class AffsetClient {
19
23
  config;
20
24
  constructor(config) {
21
- this.config = config;
25
+ this.config = normalizeRuntimeConfig(config);
22
26
  }
23
27
  /** Resolved once per process; the tenant timezone changes about never. */
24
28
  tenantTimezone;
@@ -100,7 +104,16 @@ export class AffsetClient {
100
104
  }
101
105
  throw new AffsetApiError(0, `Network error calling ${method} ${path}: ${err instanceof Error ? err.message : String(err)}`);
102
106
  }
103
- const text = await res.text();
107
+ let text;
108
+ try {
109
+ text = await readResponseText(res, MAX_API_RESPONSE_BYTES);
110
+ }
111
+ catch (err) {
112
+ if (err instanceof ResponseTooLargeError) {
113
+ throw new AffsetApiError(res.ok ? 0 : res.status, `Response too large calling ${method} ${path}: ${err.message}.`);
114
+ }
115
+ throw err;
116
+ }
104
117
  let data = null;
105
118
  if (text) {
106
119
  try {
@@ -0,0 +1,7 @@
1
+ import type { Config } from "./runtimeConfig.js";
2
+ export type { Config } from "./runtimeConfig.js";
3
+ /**
4
+ * Read and validate config from the environment. Throws with an actionable
5
+ * message listing every missing variable — surfaced to the user at startup.
6
+ */
7
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js CHANGED
@@ -1,31 +1,9 @@
1
+ import { MAX_REQUEST_TIMEOUT_MS, MIN_REQUEST_TIMEOUT_MS, parseOriginUrl, validateApiKey, validateNamespace, } from "./runtimeConfig.js";
1
2
  const DEFAULT_TIMEOUT_MS = 30_000;
2
- const MAX_TIMEOUT_MS = 300_000;
3
+ /** Where the docs feeds live when AFFSET_DOCS_URL is not set. */
4
+ const DEFAULT_DOCS_URL = "https://affset.com";
3
5
  const TRUTHY = new Set(["1", "true", "yes", "on"]);
4
6
  const FALSY = new Set(["0", "false", "no", "off"]);
5
- /**
6
- * Same rules as lite-adserver signup: lowercase alnum + hyphens, 3–63 chars,
7
- * no leading/trailing hyphen, no consecutive hyphens. Keeps whoami's
8
- * `https://{namespace}.affset.com` URL from embedding attacker-chosen junk if
9
- * someone pastes a weird AFFSET_NAMESPACE into their MCP client config.
10
- */
11
- const NAMESPACE_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
12
- /** Loopback hosts allowed to stay on plain http — everything else must be https. */
13
- function isLoopbackHost(hostname) {
14
- return (hostname === "localhost" ||
15
- hostname === "127.0.0.1" ||
16
- hostname === "::1" ||
17
- hostname.endsWith(".localhost"));
18
- }
19
- function namespaceError(namespace) {
20
- if (namespace.length < 3 || namespace.length > 63) {
21
- return "AFFSET_NAMESPACE must be 3–63 characters.";
22
- }
23
- if (!NAMESPACE_RE.test(namespace) || namespace.includes("--")) {
24
- return ("AFFSET_NAMESPACE must be lowercase letters, numbers, and hyphens only " +
25
- "(no leading/trailing or consecutive hyphens).");
26
- }
27
- return null;
28
- }
29
7
  /**
30
8
  * Read and validate config from the environment. Throws with an actionable
31
9
  * message listing every missing variable — surfaced to the user at startup.
@@ -45,38 +23,20 @@ export function loadConfig(env = process.env) {
45
23
  throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. ` +
46
24
  `See .env.example for the expected values.`);
47
25
  }
48
- const nsErr = namespaceError(namespace);
49
- if (nsErr)
50
- throw new Error(nsErr);
51
- const normalized = baseUrl.replace(/\/+$/, "");
52
- let parsed;
53
- try {
54
- parsed = new URL(normalized);
55
- }
56
- catch {
57
- throw new Error(`AFFSET_BASE_URL is not a valid URL: ${baseUrl}`);
58
- }
59
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
60
- throw new Error(`AFFSET_BASE_URL must be http(s), got ${parsed.protocol}`);
61
- }
62
- if (parsed.username || parsed.password) {
63
- throw new Error("AFFSET_BASE_URL must not contain embedded credentials.");
64
- }
65
- if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) {
66
- throw new Error("AFFSET_BASE_URL must be an origin only (scheme, host, and optional port; no path, query, or fragment).");
67
- }
68
- // Plain http sends the Bearer API key in cleartext. Only loopback (local dev /
69
- // wrangler dev) is exempt — anything else must be https.
70
- if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
71
- throw new Error(`AFFSET_BASE_URL must be https for a non-local host (got http://${parsed.hostname}) — ` +
72
- `plain http would send the API key in cleartext.`);
73
- }
26
+ validateNamespace(namespace, "AFFSET_NAMESPACE");
27
+ const parsedApiKey = validateApiKey(apiKey, "AFFSET_API_KEY");
28
+ // Plain http on AFFSET_BASE_URL sends the Bearer API key in cleartext; the
29
+ // helper refuses it for non-loopback hosts.
30
+ const parsed = parseOriginUrl(baseUrl, "AFFSET_BASE_URL", true);
31
+ // Optional — the docs feeds are public, so this only sets where to fetch them.
32
+ const docsRaw = env.AFFSET_DOCS_URL?.trim();
33
+ const docsBaseUrl = parseOriginUrl(docsRaw || DEFAULT_DOCS_URL, "AFFSET_DOCS_URL").origin;
74
34
  const timeoutRaw = env.AFFSET_REQUEST_TIMEOUT_MS?.trim();
75
35
  let requestTimeoutMs = DEFAULT_TIMEOUT_MS;
76
36
  if (timeoutRaw) {
77
37
  const n = Number(timeoutRaw);
78
- if (!Number.isSafeInteger(n) || n < 1000 || n > MAX_TIMEOUT_MS) {
79
- throw new Error(`AFFSET_REQUEST_TIMEOUT_MS must be an integer between 1000 and ${MAX_TIMEOUT_MS}.`);
38
+ if (!Number.isSafeInteger(n) || n < MIN_REQUEST_TIMEOUT_MS || n > MAX_REQUEST_TIMEOUT_MS) {
39
+ throw new Error(`AFFSET_REQUEST_TIMEOUT_MS must be an integer between ${MIN_REQUEST_TIMEOUT_MS} and ${MAX_REQUEST_TIMEOUT_MS}.`);
80
40
  }
81
41
  requestTimeoutMs = n;
82
42
  }
@@ -91,7 +51,8 @@ export function loadConfig(env = process.env) {
91
51
  }
92
52
  return {
93
53
  baseUrl: parsed.origin,
94
- apiKey: apiKey,
54
+ docsBaseUrl,
55
+ apiKey: parsedApiKey,
95
56
  namespace: namespace,
96
57
  requestTimeoutMs,
97
58
  readOnly,
package/dist/core.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Runtime-agnostic library surface of @affset/mcp (REMOTE-MCP-PRD.md §5.6).
3
+ *
4
+ * Everything exported here runs on any fetch-capable runtime (Node ≥22.13,
5
+ * Cloudflare Workers) — no `process.env`, no `node:` imports. The stdio
6
+ * entrypoint (`dist/index.js`, the `affset-mcp` bin) layers env-var loading on
7
+ * top of this; the remote MCP gateway imports this surface directly and
8
+ * supplies per-grant credentials instead.
9
+ *
10
+ * `loadConfig` (env-var parsing) is deliberately NOT exported: it is the
11
+ * stdio entrypoint's concern, and its signature drags Node types into
12
+ * consumers.
13
+ */
14
+ export { registerAffsetTools, type AffsetToolServer, type RegisterAffsetToolsOptions, type ToolCallEvent, } from "./registerTools.js";
15
+ export type { Config } from "./runtimeConfig.js";
16
+ export { AffsetClient, AffsetApiError } from "./client.js";
17
+ export { DOCS_FEEDS, fetchDocsFeed, DocsFetchError, type DocsFeed } from "./docs.js";
18
+ export { VERSION } from "./version.js";