@affset/mcp 0.1.0 → 0.2.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 (66) hide show
  1. package/.env.example +5 -0
  2. package/README.md +158 -37
  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/index.js +0 -0
  13. package/dist/lib/format.d.ts +46 -0
  14. package/dist/lib/format.js +3 -0
  15. package/dist/lib/integrationUrls.d.ts +57 -0
  16. package/dist/lib/linkArgs.d.ts +27 -0
  17. package/dist/lib/patch.d.ts +10 -0
  18. package/dist/lib/payoutRules.d.ts +38 -0
  19. package/dist/lib/readBody.d.ts +17 -0
  20. package/dist/lib/readBody.js +69 -0
  21. package/dist/lib/targeting.d.ts +51 -0
  22. package/dist/lib/time.d.ts +38 -0
  23. package/dist/lib/toolResult.d.ts +7 -0
  24. package/dist/lib/urls.d.ts +2 -0
  25. package/dist/lib/zones.d.ts +24 -0
  26. package/dist/registerTools.d.ts +40 -0
  27. package/dist/registerTools.js +401 -0
  28. package/dist/runtimeConfig.d.ts +37 -0
  29. package/dist/runtimeConfig.js +93 -0
  30. package/dist/server.d.ts +4 -0
  31. package/dist/server.js +3 -306
  32. package/dist/tools/createCampaign.d.ts +25 -0
  33. package/dist/tools/createCampaign.js +3 -2
  34. package/dist/tools/createTeamMember.d.ts +26 -0
  35. package/dist/tools/createTeamMember.js +204 -0
  36. package/dist/tools/createZone.d.ts +22 -0
  37. package/dist/tools/cutZones.d.ts +29 -0
  38. package/dist/tools/deletePayoutRule.d.ts +16 -0
  39. package/dist/tools/getCampaign.d.ts +12 -0
  40. package/dist/tools/getCampaign.js +150 -0
  41. package/dist/tools/getStats.d.ts +40 -0
  42. package/dist/tools/getStats.js +37 -2
  43. package/dist/tools/getTrackingLink.d.ts +23 -0
  44. package/dist/tools/getZoneUrl.d.ts +21 -0
  45. package/dist/tools/listCampaigns.d.ts +23 -0
  46. package/dist/tools/listConversions.d.ts +29 -0
  47. package/dist/tools/listPayoutRules.d.ts +12 -0
  48. package/dist/tools/listSubLabels.d.ts +5 -0
  49. package/dist/tools/listTargetingRules.d.ts +12 -0
  50. package/dist/tools/listTargetingTypes.d.ts +5 -0
  51. package/dist/tools/listTeam.d.ts +14 -0
  52. package/dist/tools/listZones.d.ts +23 -0
  53. package/dist/tools/removeTargetingRule.d.ts +21 -0
  54. package/dist/tools/setCampaignStatus.d.ts +18 -0
  55. package/dist/tools/setPayoutGoal.d.ts +16 -0
  56. package/dist/tools/setPayoutRule.d.ts +18 -0
  57. package/dist/tools/setSubLabels.d.ts +22 -0
  58. package/dist/tools/setTargetingRule.d.ts +21 -0
  59. package/dist/tools/updateCampaign.d.ts +35 -0
  60. package/dist/tools/updateZone.d.ts +25 -0
  61. package/dist/tools/whoami.d.ts +6 -0
  62. package/dist/types.d.ts +210 -0
  63. package/dist/types.js +1 -0
  64. package/dist/version.d.ts +7 -0
  65. package/dist/version.js +8 -0
  66. package/package.json +16 -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
@@ -9,32 +9,34 @@ It's a thin wrapper over the existing affset tenant API (`Bearer` token +
9
9
 
10
10
  ## Tools
11
11
 
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_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. |
20
- | `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. |
21
- | `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. |
22
- | `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. |
23
- | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
24
- | `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. |
25
- | `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. |
26
- | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
27
- | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
28
- | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
29
- | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
30
- | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
31
- | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
32
- | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
33
- | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
34
- | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
35
- | `list_sub_labels` | List tenant display names for sub1–sub5. |
36
- | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
37
- | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. |
12
+ | Tool | What it does |
13
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
+ | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. |
15
+ | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, advertiser, publisher, …), optionally narrowed with `advertiser_email`/`publisher_email` filters (one user, any group_by). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. Per-user groupings and filters are owner/manager plus the matching side's manager role. |
16
+ | `list_campaigns` | List campaigns (status / name filter, pagination). |
17
+ | `get_campaign` | One campaign's full record — every field (untruncated offer URL, exact schedule, budgets/pacing, silent flag, payout goal type) plus its targeting rules and payout rules, in one call. |
18
+ | `list_zones` | List traffic-source zones (status / name filter, pagination). |
19
+ | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
20
+ | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
21
+ | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. |
22
+ | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
23
+ | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
24
+ | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
25
+ | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
26
+ | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. |
27
+ | `get_tracking_link` | The `/track/click` link for an existing campaign + zone straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. |
28
+ | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
29
+ | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
30
+ | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
31
+ | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
32
+ | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
33
+ | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
34
+ | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
35
+ | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
36
+ | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
37
+ | `list_sub_labels` | List tenant display names for sub1–sub5. |
38
+ | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
39
+ | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. |
38
40
 
39
41
  ### Which URL do I give the network?
40
42
 
@@ -63,22 +65,46 @@ URL. Both URL types also require an active zone. Geo whitelist is enforced in `/
63
65
  only — the direct tracking link is **not** geo-gated, but it still requires an active,
64
66
  currently serviceable campaign.
65
67
 
68
+ ## Documentation resources
69
+
70
+ Beyond the tools, the server exposes the affset **API reference** as MCP
71
+ [resources](https://modelcontextprotocol.io/docs/concepts/resources), so an
72
+ assistant can answer "how does conversion tracking work?" or "what does `/serve`
73
+ accept?" from the docs themselves — not just from the tool schemas.
74
+
75
+ | Resource URI | Type | Content |
76
+ | ---------------------------------- | ------------------ | ------------------------------------------------------------ |
77
+ | `affset://docs/api-reference` | `text/markdown` | The full API reference — endpoints, auth, roles, examples. |
78
+ | `affset://docs/api-reference.json` | `application/json` | The same reference as structured data, for programmatic use. |
79
+
80
+ They're the exact content published at [affset.com/docs](https://affset.com/docs),
81
+ generated from one source, and **fetched at read time** from `AFFSET_DOCS_URL`
82
+ (`{origin}/api-reference.md` and `{origin}/api-reference.json`) — so they always
83
+ reflect the currently published docs, not a copy pinned to this package. The
84
+ fetch sends **no credentials** (the docs are public and live on a different
85
+ origin than the tenant API). HTML SPA fallbacks, redirects, invalid JSON, and
86
+ oversized bodies are rejected. Both resources are always available, including
87
+ under `AFFSET_READ_ONLY`.
88
+
66
89
  ## Configuration
67
90
 
68
91
  All config comes from environment variables (never hard-coded):
69
92
 
70
- | Variable | Description |
71
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
- | `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. |
73
- | `AFFSET_API_KEY` | Tenant API key. Its namespace must match `AFFSET_NAMESPACE`. |
74
- | `AFFSET_NAMESPACE` | Tenant namespace (lowercase letters, numbers, hyphens; 3–63 chars — same rules as signup). |
75
- | `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. |
76
- | `AFFSET_REQUEST_TIMEOUT_MS` | Optional, default `30000`. Per-request HTTP timeout in milliseconds (`1000`–`300000`). |
93
+ | Variable | Description |
94
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | `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. |
96
+ | `AFFSET_API_KEY` | Tenant API key. Its namespace must match `AFFSET_NAMESPACE`. |
97
+ | `AFFSET_NAMESPACE` | Tenant namespace (lowercase letters, numbers, hyphens; 3–63 chars — same rules as signup). |
98
+ | `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. |
99
+ | `AFFSET_REQUEST_TIMEOUT_MS` | Optional, default `30000`. Per-request HTTP timeout in milliseconds (`1000`–`300000`). |
100
+ | `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. |
77
101
 
78
102
  See [`.env.example`](.env.example).
79
103
 
80
104
  ## Install
81
105
 
106
+ Requires Node.js 22.13 or newer.
107
+
82
108
  ### From npm (recommended)
83
109
 
84
110
  No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
@@ -110,9 +136,44 @@ claude mcp add affset \
110
136
  -- npx -y @affset/mcp
111
137
  ```
112
138
 
139
+ Same env flags with `-- npx -y github:affset/mcp` if you install from GitHub
140
+ instead of the npm registry (see below).
141
+
113
142
  Add `-e AFFSET_READ_ONLY=true` for a stats/reporting-only instance (see
114
143
  [Security](#security)).
115
144
 
145
+ ### From GitHub directly (no npm publish required)
146
+
147
+ `npx` can install straight from the git repo instead of the npm registry —
148
+ useful if you'd rather not publish, or just want to track `main` without a
149
+ release step:
150
+
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "affset": {
155
+ "command": "npx",
156
+ "args": ["-y", "github:affset/mcp"],
157
+ "env": {
158
+ "AFFSET_BASE_URL": "https://api.affset.com",
159
+ "AFFSET_API_KEY": "sk_live_...",
160
+ "AFFSET_NAMESPACE": "your-namespace"
161
+ }
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ A push to `main` makes that commit available to this unpinned install path — no
168
+ npm publish is required. On resolution, npm fetches the repository and runs the
169
+ `prepare` script to build `dist/` before starting the binary. npm may reuse its
170
+ cache on later starts; an already running MCP process is not updated until it is
171
+ restarted and `npx` resolves the dependency again.
172
+
173
+ For reproducible deployments, pin a reviewed ref instead of floating on `main`:
174
+ `github:affset/mcp#<commit-sha>` or `github:affset/mcp#<tag>`. Restart the MCP
175
+ process deliberately when you want it to resolve and run a newer revision.
176
+
116
177
  ### From source
117
178
 
118
179
  ```bash
@@ -128,12 +189,20 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
128
189
 
129
190
  > **list paused campaigns** → `list_campaigns(status: "paused")`
130
191
  >
192
+ > **show me everything about campaign 42** → `get_campaign(campaign_id: 42)`
193
+ >
131
194
  > **show zones** → `list_zones()`
132
195
  >
133
196
  > **who's on the team?** → `list_team()`
134
197
  >
198
+ > **add sarah@offer.com as a publisher** → `create_team_member(email: "sarah@offer.com", role: "publisher")` (dry-run) → confirm
199
+ >
135
200
  > **stats for today by sub1** → `get_stats(group_by: "sub1")`
136
201
  >
202
+ > **stats by advertiser** → `get_stats(group_by: "advertiser_email")`
203
+ >
204
+ > **stats for one publisher, grouped by zone** → `get_stats(group_by: "zone_id", publisher_email: "publisher@example.com")`
205
+ >
137
206
  > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
138
207
  >
139
208
  > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
@@ -173,7 +242,12 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
173
242
  ## Notes & limits
174
243
 
175
244
  - **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
176
- calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` filters.
245
+ calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` /
246
+ `conversion_type` / `advertiser_email` / `publisher_email` filters. The two email
247
+ filters select one user's campaigns or zones without changing `group_by`; access
248
+ is limited to owner/manager or the corresponding scoped manager role. Filtering
249
+ by `conversion_type` returns conversion rows only (impressions, clicks and media
250
+ cost are zero).
177
251
  - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
178
252
  cost data imported for the slice.
179
253
  - List endpoints have **no server-side name search** — `name_contains` filters the
@@ -206,7 +280,11 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
206
280
  has no campaign/zone/date filters; optional filters apply to the current page only.
207
281
  Rows do not include campaign_id/zone_id. Publisher-side roles do not see `spend` and
208
282
  advertiser-side roles do not see `payout`, so `zero_payout` needs a role that can.
209
- - Out of scope: deleting campaigns/zones/conversions, billing, creative management, invite flows.
283
+ - **`create_team_member`** creates the API key directly (like the dashboard's "Add Team
284
+ Member") — it does not send an invite email. Hand the returned key to the person
285
+ yourself. Revoking/removing a team member is not yet a tool; use the dashboard's
286
+ Team page.
287
+ - Out of scope: deleting campaigns/zones/conversions, billing, creative management.
210
288
  - **Tenant signup is deliberately not a tool.** `POST /api/public/create-instance`
211
289
  is Origin-gated and fails closed, which is what keeps signup browser-only; a
212
290
  server-side caller would have to spoof an allowlisted Origin to get past it. The
@@ -215,6 +293,44 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
215
293
  startup — so it could not use a tenant it just created. Sign up in the dashboard,
216
294
  then point a server instance at the new namespace.
217
295
 
296
+ ## Using as a library
297
+
298
+ Since 0.2.0 the package doubles as a runtime-agnostic library: everything the
299
+ stdio server registers (tools, docs resources, read-only stripping) is exposed
300
+ as one helper that runs on any fetch-capable runtime — Node ≥22.13 or Cloudflare
301
+ Workers. The hosted affset MCP gateway (`mcp.affset.com`, in progress) consumes
302
+ exactly this surface, so the remote roster can never drift from stdio.
303
+
304
+ ```ts
305
+ import { registerAffsetTools, type Config } from "@affset/mcp/core";
306
+
307
+ const config: Config = {
308
+ baseUrl: "https://api.affset.com",
309
+ docsBaseUrl: "https://affset.com",
310
+ apiKey: perRequestKey, // e.g. an OAuth grant's backing credential
311
+ namespace: tenantNamespace,
312
+ requestTimeoutMs: 30_000,
313
+ readOnly: scope === "read", // never registers tools without readOnlyHint: true
314
+ };
315
+
316
+ registerAffsetTools(server, config); // server: your own McpServer instance
317
+ ```
318
+
319
+ `registerAffsetTools` accepts your `McpServer` structurally, so your own
320
+ `@modelcontextprotocol/sdk` install works — no need to match this package's
321
+ copy. Env-var loading (`AFFSET_*`) is deliberately not part of the library
322
+ surface; it belongs to the stdio entrypoint only. A third, optional
323
+ `{ onToolCall }` argument reports only tool name, duration, and success/error
324
+ status for transport-owned audit logging; arguments and output are never
325
+ included.
326
+
327
+ The library validates and normalizes `config` before registering anything.
328
+ Remote API origins must use HTTPS (plain HTTP is accepted only on loopback),
329
+ origins cannot contain credentials or paths, and invalid namespaces, timeouts,
330
+ API keys, or non-boolean read-only settings fail closed at startup. The public
331
+ declarations do not require Node ambient types, so the same import type-checks
332
+ in Workers and other web-standard runtimes.
333
+
218
334
  ## Development
219
335
 
220
336
  ```bash
@@ -231,12 +347,17 @@ npm run dev # watch mode
231
347
 
232
348
  - No secrets in the repo; credentials come from the environment at runtime.
233
349
  - `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
350
+ - Tenant API responses are streamed under a 5 MB hard limit; larger bodies are
351
+ cancelled before parsing or reaching model context.
234
352
  - stdout is the JSON-RPC channel — all logs go to stderr.
235
353
  - `list_team` redacts API tokens.
236
354
  - All mutations (including creates) follow **show → confirm → apply**.
237
355
  - Create a **dedicated, least-privilege API key** for the MCP rather than reusing an
238
- owner key, and give it an expiry — affset's RBAC roles (owner/manager/publisher/
239
- advertiser) apply to MCP tool calls exactly as they do to the dashboard.
356
+ owner key, and give it an expiry — affset's RBAC roles (owner / manager /
357
+ publisher / advertiser / advertiser_manager / publisher_manager) apply to MCP
358
+ tool calls exactly as they do to the dashboard.
359
+ - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
360
+ floating `main` spec can run newer repository code the next time `npx` resolves it.
240
361
 
241
362
  ### Prompt injection via conversion/click data
242
363
 
@@ -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";
package/dist/core.js 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, } from "./registerTools.js";
15
+ export { AffsetClient, AffsetApiError } from "./client.js";
16
+ export { DOCS_FEEDS, fetchDocsFeed, DocsFetchError } from "./docs.js";
17
+ export { VERSION } from "./version.js";
18
+ //# sourceMappingURL=core.js.map
package/dist/docs.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type Config } from "./runtimeConfig.js";
2
+ /**
3
+ * Fetches the affset documentation feeds that back the MCP documentation
4
+ * resources. These are static files published by the marketing site
5
+ * (see lite-adserver-home/scripts/generate-docs.mjs), generated from the same
6
+ * source as the /docs page, so the resource content never drifts from the docs.
7
+ *
8
+ * Fetched at read time — the resource is always the currently published docs,
9
+ * not a snapshot pinned to this package version. Unlike the tenant API client,
10
+ * this sends NO credentials: the docs are public, and the docs origin
11
+ * (AFFSET_DOCS_URL) is deliberately not the API host that holds the API key.
12
+ */
13
+ /** One published feed: the URI path segment and how it's served. */
14
+ export interface DocsFeed {
15
+ /** Trailing path on the docs origin, e.g. "api-reference.md". */
16
+ file: string;
17
+ mimeType: string;
18
+ }
19
+ export declare const DOCS_FEEDS: {
20
+ readonly markdown: {
21
+ readonly file: "api-reference.md";
22
+ readonly mimeType: "text/markdown";
23
+ };
24
+ readonly json: {
25
+ readonly file: "api-reference.json";
26
+ readonly mimeType: "application/json";
27
+ };
28
+ };
29
+ /** Thrown when a docs feed can't be fetched or looks wrong. */
30
+ export declare class DocsFetchError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Fetch one docs feed and return its text. Throws {@link DocsFetchError} with an
35
+ * actionable message on any network error, non-2xx status, HTML SPA fallback,
36
+ * invalid body, or oversized response — the MCP layer surfaces that to the
37
+ * caller as the resource read failure.
38
+ */
39
+ export declare function fetchDocsFeed(config: Config, feed: DocsFeed): Promise<string>;