@affset/mcp 0.1.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 (43) hide show
  1. package/.env.example +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +263 -0
  4. package/dist/client.js +131 -0
  5. package/dist/config.js +100 -0
  6. package/dist/index.js +21 -0
  7. package/dist/lib/format.js +148 -0
  8. package/dist/lib/integrationUrls.js +128 -0
  9. package/dist/lib/linkArgs.js +53 -0
  10. package/dist/lib/patch.js +23 -0
  11. package/dist/lib/payoutRules.js +46 -0
  12. package/dist/lib/targeting.js +246 -0
  13. package/dist/lib/time.js +234 -0
  14. package/dist/lib/toolResult.js +35 -0
  15. package/dist/lib/urls.js +15 -0
  16. package/dist/lib/zones.js +83 -0
  17. package/dist/server.js +316 -0
  18. package/dist/tools/createCampaign.js +211 -0
  19. package/dist/tools/createZone.js +117 -0
  20. package/dist/tools/cutZones.js +224 -0
  21. package/dist/tools/deletePayoutRule.js +70 -0
  22. package/dist/tools/getStats.js +72 -0
  23. package/dist/tools/getTrackingLink.js +119 -0
  24. package/dist/tools/getZoneUrl.js +94 -0
  25. package/dist/tools/listCampaigns.js +81 -0
  26. package/dist/tools/listConversions.js +237 -0
  27. package/dist/tools/listPayoutRules.js +72 -0
  28. package/dist/tools/listSubLabels.js +31 -0
  29. package/dist/tools/listTargetingRules.js +69 -0
  30. package/dist/tools/listTargetingTypes.js +39 -0
  31. package/dist/tools/listTeam.js +57 -0
  32. package/dist/tools/listZones.js +70 -0
  33. package/dist/tools/removeTargetingRule.js +109 -0
  34. package/dist/tools/setCampaignStatus.js +32 -0
  35. package/dist/tools/setPayoutGoal.js +69 -0
  36. package/dist/tools/setPayoutRule.js +119 -0
  37. package/dist/tools/setSubLabels.js +102 -0
  38. package/dist/tools/setTargetingRule.js +125 -0
  39. package/dist/tools/updateCampaign.js +218 -0
  40. package/dist/tools/updateZone.js +118 -0
  41. package/dist/tools/whoami.js +42 -0
  42. package/dist/types.js +18 -0
  43. package/package.json +70 -0
package/.env.example ADDED
@@ -0,0 +1,18 @@
1
+ # Copy to .env and fill in. One MCP instance == one affset tenant.
2
+
3
+ # Origin of your affset ad server API (no path, query, or embedded credentials).
4
+ AFFSET_BASE_URL=https://api.affset.com
5
+
6
+ # Tenant API key (Bearer token). Its namespace must match AFFSET_NAMESPACE.
7
+ AFFSET_API_KEY=sk_live_xxxxxxxxxxxxxxxx
8
+
9
+ # Tenant namespace this key belongs to (lowercase letters, numbers, hyphens; 3–63 chars).
10
+ AFFSET_NAMESPACE=your-namespace
11
+
12
+ # Optional. Per-request HTTP timeout in milliseconds (1000–300000; default 30000).
13
+ # AFFSET_REQUEST_TIMEOUT_MS=30000
14
+
15
+ # Optional. Set to true to register only read-only tools (no create/update/delete/
16
+ # cut) — recommended for stats/reporting sessions, especially with an MCP client
17
+ # that auto-approves tool calls. See README Security section. Default false.
18
+ # AFFSET_READ_ONLY=false
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elusione OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # affset MCP server
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that lets a media buyer run affset
4
+ from a chat client — pull stats, manage campaigns/zones/team, payouts, targeting,
5
+ sub labels, and cut underperforming zones in plain language, no dashboard.
6
+
7
+ It's a thin wrapper over the existing affset tenant API (`Bearer` token +
8
+ `X-Namespace`). One server instance serves one tenant.
9
+
10
+ ## Tools
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. |
38
+
39
+ ### Which URL do I give the network?
40
+
41
+ | | `get_zone_url` (`/serve/{zone}`) | `get_tracking_link` (`/track/click/{campaign}/{zone}`) |
42
+ | ------------------------ | ----------------------------------------- | ------------------------------------------------------ |
43
+ | Picks the campaign | affset, from the zone's rotation | you, one fixed campaign |
44
+ | Needs an active campaign | **yes** — otherwise traffic back / unsold | **yes** — otherwise 404 |
45
+ | Needs an active zone | **yes** | **yes** |
46
+ | Geo & targeting rules | enforced | **not** enforced |
47
+ | `cost=` lands on | the impression row | the click row |
48
+
49
+ Use one or the other for a given traffic stream — never both with `cost=`, or the
50
+ media cost is counted twice.
51
+
52
+ Both use the tenant's **custom API domain** when one is set, since the URL gets pasted
53
+ into the network verbatim. Macros (`{clickid}`, `[CLICK_ID]`, `${SUBID}`) are inserted
54
+ without percent-encoding — the source expands them before the request reaches affset.
55
+
56
+ `cut_zones` only ever **adds** zones to a campaign's blacklist, and does a
57
+ read-merge-write so existing targeting rules are never touched.
58
+
59
+ `create_campaign` needs a traffic-source **zone** for the tracking link: pass
60
+ `zone_id`, or let it auto-pick when the namespace has exactly one active zone.
61
+ Campaigns are created `paused`; activate them before sending traffic through either
62
+ URL. Both URL types also require an active zone. Geo whitelist is enforced in `/serve`
63
+ only — the direct tracking link is **not** geo-gated, but it still requires an active,
64
+ currently serviceable campaign.
65
+
66
+ ## Configuration
67
+
68
+ All config comes from environment variables (never hard-coded):
69
+
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`). |
77
+
78
+ See [`.env.example`](.env.example).
79
+
80
+ ## Install
81
+
82
+ ### From npm (recommended)
83
+
84
+ No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
85
+ (`claude_desktop_config.json`):
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "affset": {
91
+ "command": "npx",
92
+ "args": ["-y", "@affset/mcp"],
93
+ "env": {
94
+ "AFFSET_BASE_URL": "https://api.affset.com",
95
+ "AFFSET_API_KEY": "sk_live_...",
96
+ "AFFSET_NAMESPACE": "your-namespace"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ For **Claude Code**:
104
+
105
+ ```bash
106
+ claude mcp add affset \
107
+ -e AFFSET_BASE_URL=https://api.affset.com \
108
+ -e AFFSET_API_KEY=sk_live_... \
109
+ -e AFFSET_NAMESPACE=your-namespace \
110
+ -- npx -y @affset/mcp
111
+ ```
112
+
113
+ Add `-e AFFSET_READ_ONLY=true` for a stats/reporting-only instance (see
114
+ [Security](#security)).
115
+
116
+ ### From source
117
+
118
+ ```bash
119
+ git clone https://github.com/affset/mcp.git affset-mcp
120
+ cd affset-mcp
121
+ npm install # builds via the prepare script
122
+ ```
123
+
124
+ Then point your MCP client at the built entry file — swap the `npx` command above
125
+ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]`.
126
+
127
+ ## Usage examples
128
+
129
+ > **list paused campaigns** → `list_campaigns(status: "paused")`
130
+ >
131
+ > **show zones** → `list_zones()`
132
+ >
133
+ > **who's on the team?** → `list_team()`
134
+ >
135
+ > **stats for today by sub1** → `get_stats(group_by: "sub1")`
136
+ >
137
+ > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
138
+ >
139
+ > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
140
+ > → `create_campaign(user_email: "buyer@example.com", offer_url: "https://offer.example/lp?s={click_id}", geo: ["BR"], payout: 2)` (dry-run) → confirm
141
+ >
142
+ > **what URL do I paste into RichAds?** → `get_zone_url(cost: "{cost}")`
143
+ >
144
+ > **give me the link for campaign 42 again** → `get_tracking_link(campaign_id: 42)`
145
+ >
146
+ > **run campaign 42** → `set_campaign_status(campaign_id: 42, action: "run")` (dry-run) → confirm
147
+ >
148
+ > **pause campaign 42** → `set_campaign_status(campaign_id: 42, action: "pause")` (dry-run) → confirm
149
+ >
150
+ > **set zone postback** → `update_zone(zone_id, postback_url: "…")` (dry-run) → confirm
151
+ >
152
+ > **cut zones with CR < 0.2% and spend > $5**
153
+ > → `cut_zones(campaign_id, cr_max: 0.002, spend_min: 5)` (dry-run) → confirm
154
+ >
155
+ > **show payouts for campaign 42** → `list_payout_rules(campaign_id: 42)`
156
+ >
157
+ > **set zone payout to $3** → `set_payout_rule(campaign_id: 42, payout: 3, zone_id: "…")` (dry-run) → confirm
158
+ >
159
+ > **only pay on deposit conversions** → `set_payout_goal(campaign_id: 42, goal_type: "deposit")` (dry-run) → confirm
160
+ >
161
+ > **what targeting types exist?** → `list_targeting_types()`
162
+ >
163
+ > **whitelist BR+MX on campaign 42** → `set_targeting_rule(campaign_id: 42, type: "geo", method: "whitelist", rule: "BR,MX")` (dry-run) → confirm
164
+ >
165
+ > **name sub1 Zone, sub2 Creative** → `set_sub_labels(sub1: "Zone", sub2: "Creative")` (dry-run) → confirm
166
+ >
167
+ > **show recent conversions** → `list_conversions()`
168
+ >
169
+ > **find $0 payouts (goal miss?)** → `list_conversions(zero_payout: true)`
170
+ >
171
+ > **lookup by source click id** → `list_conversions(source_click_id: "abc123")`
172
+
173
+ ## Notes & limits
174
+
175
+ - **`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.
177
+ - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
178
+ cost data imported for the slice.
179
+ - List endpoints have **no server-side name search** — `name_contains` filters the
180
+ current page client-side.
181
+ - Date-range presets, `YYYY-MM-DD` bounds and rendered timestamps all resolve in the
182
+ **tenant timezone** (read once from `/api/tenant`), so a window lines up with the
183
+ date buckets `group_by=date` returns instead of straddling two of them. Explicit
184
+ timestamps must include `Z` or a UTC offset.
185
+ - **All mutations** (creates, updates, cuts, deletes) stay on dry-run →
186
+ `confirm: true`. Creates are additive once confirmed and echo what was written.
187
+ - Activating a campaign or creating a zone can return **402 plan limit** — the
188
+ error surfaces dimension / current / limit.
189
+ - **Payout resolution** at conversion: zone-specific → global → $0. Goal type gates
190
+ spend/payout by pixel `type=` match; non-matching events still record at $0.
191
+ Payouts go down to `$0.00001`, so payout amounts print at up to five decimals.
192
+ - **Changing a payout is delete + create** — the API has no update and the
193
+ (campaign, zone) pair is unique. `set_payout_rule` restores the previous payout if
194
+ the create fails, and says so loudly in the one case where it cannot.
195
+ - **Targeting** is enforced on `/serve` only — not on direct tracking links.
196
+ `set_targeting_rule` / `remove_targeting_rule` merge safely; other rules are kept.
197
+ - **Targeting values are matched exactly and case-sensitively** at serve time (geo
198
+ from `CF-IPCountry`, os/browser from the user agent, device type from a fixed set).
199
+ `set_targeting_rule` normalises what it can (`br,mx` → `BR,MX`, `android` → `Android`)
200
+ and rejects what could never match — an unmatched whitelist silently stops delivery.
201
+ - **`capping`, `weekdays` and `hours` are seeded but never evaluated** by `/serve`.
202
+ `set_targeting_rule` refuses to write them (they would read as working targeting
203
+ while the campaign kept buying); `list_targeting_types` flags them. Use
204
+ `unique_users` (`visits/hours`) for frequency capping.
205
+ - **`list_conversions`** is the conversion audit trail (not aggregated stats). The API
206
+ has no campaign/zone/date filters; optional filters apply to the current page only.
207
+ Rows do not include campaign_id/zone_id. Publisher-side roles do not see `spend` and
208
+ 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.
210
+ - **Tenant signup is deliberately not a tool.** `POST /api/public/create-instance`
211
+ is Origin-gated and fails closed, which is what keeps signup browser-only; a
212
+ server-side caller would have to spoof an allowlisted Origin to get past it. The
213
+ endpoint also withholds the API key when email delivery is configured (it sends a
214
+ magic link instead), and this server binds one namespace from the environment at
215
+ startup — so it could not use a tenant it just created. Sign up in the dashboard,
216
+ then point a server instance at the new namespace.
217
+
218
+ ## Development
219
+
220
+ ```bash
221
+ npm run type-check # tsc --noEmit
222
+ npm run lint # eslint src
223
+ npm run format # prettier --write .
224
+ npm run build # compile to dist/
225
+ npm test # build + node --test over dist/**/*.test.js
226
+ npm run check-all # lint + format:check + type-check + test — CI runs this
227
+ npm run dev # watch mode
228
+ ```
229
+
230
+ ## Security
231
+
232
+ - No secrets in the repo; credentials come from the environment at runtime.
233
+ - `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
234
+ - stdout is the JSON-RPC channel — all logs go to stderr.
235
+ - `list_team` redacts API tokens.
236
+ - All mutations (including creates) follow **show → confirm → apply**.
237
+ - 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.
240
+
241
+ ### Prompt injection via conversion/click data
242
+
243
+ `get_stats`, `list_conversions` and `cut_zones` surface data that ultimately comes from
244
+ public, unauthenticated endpoints — a traffic source's click macros (`sub1`–`sub5`,
245
+ `source_click_id`) and a conversion pixel's raw query string (`list_conversions`'
246
+ payload detail). Anyone who can generate a click or fire a pixel controls those bytes,
247
+ and they land in the model's context when you ask about stats or conversions.
248
+
249
+ Mitigations in place:
250
+
251
+ - Untrusted fields are length-capped and escaped before rendering (`mdCell`,
252
+ `capUntrusted` in `src/lib/format.ts`), and the conversion-payload block carries an
253
+ explicit "treat as data, not instructions" notice.
254
+ - `confirm: true` on mutating tools is a **model-level** safety net, not a security
255
+ boundary — a model that has been steered by injected content can supply
256
+ `confirm: true` itself. The only real boundary is your MCP client's per-call tool
257
+ approval and **`AFFSET_READ_ONLY`**.
258
+
259
+ **Set `AFFSET_READ_ONLY=true`** for any session where you're mainly reading stats/
260
+ conversions, especially with an MCP client that auto-approves tool calls. It removes
261
+ every mutation tool from the server entirely — not hidden behind a prompt, unavailable
262
+ to call. Reserve a read-write instance (or a separate one) for sessions where you're
263
+ actively managing campaigns/zones/payouts and are reviewing each confirm yourself.
package/dist/client.js ADDED
@@ -0,0 +1,131 @@
1
+ /** Thrown when the affset API returns a non-2xx response. */
2
+ export class AffsetApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, message,
6
+ /** Parsed JSON body when present (e.g. plan-limit details). */
7
+ body) {
8
+ super(message);
9
+ this.status = status;
10
+ this.body = body;
11
+ this.name = "AffsetApiError";
12
+ }
13
+ }
14
+ /**
15
+ * Thin typed HTTP client over the affset tenant API. Injects the Bearer token
16
+ * and `X-Namespace` header on every request and normalises error handling.
17
+ */
18
+ export class AffsetClient {
19
+ config;
20
+ constructor(config) {
21
+ this.config = config;
22
+ }
23
+ /** Resolved once per process; the tenant timezone changes about never. */
24
+ tenantTimezone;
25
+ /**
26
+ * The tenant's timezone (IANA name). Date ranges must be resolved in it, not
27
+ * in the host machine's zone, so that a requested window lines up with the
28
+ * date buckets `/api/stats?group_by=date` returns.
29
+ *
30
+ * Falls back to UTC if the setting cannot be read — a stats query with a
31
+ * slightly-off window beats failing the whole call.
32
+ */
33
+ async getTenantTimezone() {
34
+ try {
35
+ return await this.readTenantTimezone();
36
+ }
37
+ catch {
38
+ return "UTC";
39
+ }
40
+ }
41
+ /**
42
+ * Tenant timezone for a write whose meaning depends on local midnight. Unlike
43
+ * stats reads, silently falling back to UTC here could change a schedule, so
44
+ * callers get the underlying API error and must stop the mutation.
45
+ */
46
+ async getRequiredTenantTimezone() {
47
+ return this.readTenantTimezone();
48
+ }
49
+ readTenantTimezone() {
50
+ this.tenantTimezone ??= this.get("/api/tenant").then((settings) => settings.timezone?.trim() || "UTC");
51
+ return this.tenantTimezone;
52
+ }
53
+ async get(path, query) {
54
+ return this.request("GET", path, { query });
55
+ }
56
+ async post(path, body) {
57
+ return this.request("POST", path, { body });
58
+ }
59
+ async put(path, body) {
60
+ return this.request("PUT", path, { body });
61
+ }
62
+ async delete(path, query) {
63
+ return this.request("DELETE", path, { query });
64
+ }
65
+ async request(method, path, opts = {}) {
66
+ // Defense in depth: every call site today passes a hardcoded `/api/...`
67
+ // path, but `new URL(path, base)` would quietly redirect to an absolute or
68
+ // protocol-relative URL if a future tool ever interpolated user input into
69
+ // `path`. Refuse anything that isn't a same-origin relative path.
70
+ if (!path.startsWith("/") || path.startsWith("//")) {
71
+ throw new AffsetApiError(0, `Refusing non-relative API path (must start with a single /): ${path}`);
72
+ }
73
+ const url = new URL(path, `${this.config.baseUrl}/`);
74
+ if (url.origin !== new URL(this.config.baseUrl).origin) {
75
+ throw new AffsetApiError(0, `Refusing cross-origin API path: ${path}`);
76
+ }
77
+ for (const [key, value] of Object.entries(opts.query ?? {})) {
78
+ if (value !== undefined)
79
+ url.searchParams.set(key, String(value));
80
+ }
81
+ const headers = {
82
+ Authorization: `Bearer ${this.config.apiKey}`,
83
+ "X-Namespace": this.config.namespace,
84
+ Accept: "application/json",
85
+ };
86
+ if (opts.body !== undefined)
87
+ headers["Content-Type"] = "application/json";
88
+ let res;
89
+ try {
90
+ res = await fetch(url, {
91
+ method,
92
+ headers,
93
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
94
+ signal: AbortSignal.timeout(this.config.requestTimeoutMs),
95
+ });
96
+ }
97
+ catch (err) {
98
+ if (err instanceof Error && err.name === "TimeoutError") {
99
+ throw new AffsetApiError(0, `Timed out after ${this.config.requestTimeoutMs}ms calling ${method} ${path}`);
100
+ }
101
+ throw new AffsetApiError(0, `Network error calling ${method} ${path}: ${err instanceof Error ? err.message : String(err)}`);
102
+ }
103
+ const text = await res.text();
104
+ let data = null;
105
+ if (text) {
106
+ try {
107
+ data = JSON.parse(text);
108
+ }
109
+ catch {
110
+ data = text;
111
+ }
112
+ }
113
+ if (!res.ok) {
114
+ const apiMessage = extractApiError(data) ?? (res.statusText || `HTTP ${res.status}`);
115
+ throw new AffsetApiError(res.status, apiMessage, data);
116
+ }
117
+ return data;
118
+ }
119
+ }
120
+ function extractApiError(data) {
121
+ if (!data || typeof data !== "object")
122
+ return undefined;
123
+ if ("error" in data && data.error != null) {
124
+ return String(data.error);
125
+ }
126
+ if ("message" in data && data.message != null) {
127
+ return String(data.message);
128
+ }
129
+ return undefined;
130
+ }
131
+ //# sourceMappingURL=client.js.map
package/dist/config.js ADDED
@@ -0,0 +1,100 @@
1
+ const DEFAULT_TIMEOUT_MS = 30_000;
2
+ const MAX_TIMEOUT_MS = 300_000;
3
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
4
+ 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
+ /**
30
+ * Read and validate config from the environment. Throws with an actionable
31
+ * message listing every missing variable — surfaced to the user at startup.
32
+ */
33
+ export function loadConfig(env = process.env) {
34
+ const baseUrl = env.AFFSET_BASE_URL?.trim();
35
+ const apiKey = env.AFFSET_API_KEY?.trim();
36
+ const namespace = env.AFFSET_NAMESPACE?.trim();
37
+ const missing = [];
38
+ if (!baseUrl)
39
+ missing.push("AFFSET_BASE_URL");
40
+ if (!apiKey)
41
+ missing.push("AFFSET_API_KEY");
42
+ if (!namespace)
43
+ missing.push("AFFSET_NAMESPACE");
44
+ if (missing.length > 0) {
45
+ throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. ` +
46
+ `See .env.example for the expected values.`);
47
+ }
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
+ }
74
+ const timeoutRaw = env.AFFSET_REQUEST_TIMEOUT_MS?.trim();
75
+ let requestTimeoutMs = DEFAULT_TIMEOUT_MS;
76
+ if (timeoutRaw) {
77
+ 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}.`);
80
+ }
81
+ requestTimeoutMs = n;
82
+ }
83
+ const readOnlyRaw = (env.AFFSET_READ_ONLY ?? "").trim().toLowerCase();
84
+ let readOnly = false;
85
+ if (readOnlyRaw) {
86
+ if (TRUTHY.has(readOnlyRaw))
87
+ readOnly = true;
88
+ else if (!FALSY.has(readOnlyRaw)) {
89
+ throw new Error("AFFSET_READ_ONLY must be a boolean value: true/false, 1/0, yes/no, or on/off.");
90
+ }
91
+ }
92
+ return {
93
+ baseUrl: parsed.origin,
94
+ apiKey: apiKey,
95
+ namespace: namespace,
96
+ requestTimeoutMs,
97
+ readOnly,
98
+ };
99
+ }
100
+ //# sourceMappingURL=config.js.map
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { createServer } from "./server.js";
5
+ /**
6
+ * Entry point. Runs the affset MCP server over stdio.
7
+ *
8
+ * IMPORTANT: stdout is the JSON-RPC channel — all diagnostics go to stderr.
9
+ */
10
+ async function main() {
11
+ const config = loadConfig();
12
+ const server = createServer(config);
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
15
+ console.error(`affset-mcp ready (namespace: ${config.namespace}, base: ${config.baseUrl})`);
16
+ }
17
+ main().catch((err) => {
18
+ console.error(`affset-mcp failed to start: ${err instanceof Error ? err.message : String(err)}`);
19
+ process.exit(1);
20
+ });
21
+ //# sourceMappingURL=index.js.map