@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.
- package/.env.example +5 -0
- package/README.md +191 -56
- package/dist/client.d.ts +43 -0
- package/dist/client.js +15 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +15 -54
- package/dist/core.d.ts +18 -0
- package/dist/core.js +18 -0
- package/dist/docs.d.ts +39 -0
- package/dist/docs.js +105 -0
- package/dist/index.d.ts +2 -0
- package/dist/lib/format.d.ts +46 -0
- package/dist/lib/format.js +3 -0
- package/dist/lib/integrationUrls.d.ts +87 -0
- package/dist/lib/integrationUrls.js +84 -4
- package/dist/lib/linkArgs.d.ts +32 -0
- package/dist/lib/linkArgs.js +14 -3
- package/dist/lib/patch.d.ts +10 -0
- package/dist/lib/payoutRules.d.ts +38 -0
- package/dist/lib/readBody.d.ts +17 -0
- package/dist/lib/readBody.js +69 -0
- package/dist/lib/targeting.d.ts +51 -0
- package/dist/lib/time.d.ts +38 -0
- package/dist/lib/toolResult.d.ts +7 -0
- package/dist/lib/urls.d.ts +2 -0
- package/dist/lib/zones.d.ts +24 -0
- package/dist/lib/zones.js +9 -1
- package/dist/registerTools.d.ts +40 -0
- package/dist/registerTools.js +437 -0
- package/dist/runtimeConfig.d.ts +37 -0
- package/dist/runtimeConfig.js +93 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +3 -318
- package/dist/tools/createCampaign.d.ts +25 -0
- package/dist/tools/createCampaign.js +20 -8
- package/dist/tools/createTeamMember.d.ts +26 -0
- package/dist/tools/createTrafficSource.d.ts +25 -0
- package/dist/tools/createTrafficSource.js +191 -0
- package/dist/tools/createZone.d.ts +24 -0
- package/dist/tools/createZone.js +13 -1
- package/dist/tools/cutZones.d.ts +29 -0
- package/dist/tools/deletePayoutRule.d.ts +16 -0
- package/dist/tools/getCampaign.d.ts +12 -0
- package/dist/tools/getCampaign.js +150 -0
- package/dist/tools/getStats.d.ts +42 -0
- package/dist/tools/getStats.js +54 -3
- package/dist/tools/getTrackingLink.d.ts +23 -0
- package/dist/tools/getTrackingLink.js +26 -9
- package/dist/tools/getZoneUrl.d.ts +21 -0
- package/dist/tools/getZoneUrl.js +30 -13
- package/dist/tools/listCampaigns.d.ts +23 -0
- package/dist/tools/listConversions.d.ts +31 -0
- package/dist/tools/listConversions.js +29 -10
- package/dist/tools/listPayoutRules.d.ts +12 -0
- package/dist/tools/listSubLabels.d.ts +5 -0
- package/dist/tools/listTargetingRules.d.ts +12 -0
- package/dist/tools/listTargetingTypes.d.ts +5 -0
- package/dist/tools/listTeam.d.ts +14 -0
- package/dist/tools/listTrafficSources.d.ts +17 -0
- package/dist/tools/listTrafficSources.js +63 -0
- package/dist/tools/listZones.d.ts +23 -0
- package/dist/tools/listZones.js +10 -5
- package/dist/tools/removeTargetingRule.d.ts +21 -0
- package/dist/tools/setCampaignStatus.d.ts +18 -0
- package/dist/tools/setPayoutGoal.d.ts +16 -0
- package/dist/tools/setPayoutRule.d.ts +18 -0
- package/dist/tools/setSubLabels.d.ts +22 -0
- package/dist/tools/setTargetingRule.d.ts +21 -0
- package/dist/tools/updateCampaign.d.ts +35 -0
- package/dist/tools/updateTrafficSource.d.ts +25 -0
- package/dist/tools/updateTrafficSource.js +171 -0
- package/dist/tools/updateZone.d.ts +27 -0
- package/dist/tools/updateZone.js +13 -3
- package/dist/tools/whoami.d.ts +6 -0
- package/dist/types.d.ts +257 -0
- package/dist/types.js +2 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +8 -0
- 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
|
-
|
|
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, …). 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
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
> **
|
|
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(
|
|
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
|
-
> **
|
|
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`
|
|
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;
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 <
|
|
79
|
-
throw new Error(`AFFSET_REQUEST_TIMEOUT_MS must be an integer between
|
|
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
|
-
|
|
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";
|