@affset/mcp 0.1.1 → 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 (63) hide show
  1. package/.env.example +5 -0
  2. package/README.md +114 -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/lib/format.d.ts +46 -0
  13. package/dist/lib/format.js +3 -0
  14. package/dist/lib/integrationUrls.d.ts +57 -0
  15. package/dist/lib/linkArgs.d.ts +27 -0
  16. package/dist/lib/patch.d.ts +10 -0
  17. package/dist/lib/payoutRules.d.ts +38 -0
  18. package/dist/lib/readBody.d.ts +17 -0
  19. package/dist/lib/readBody.js +69 -0
  20. package/dist/lib/targeting.d.ts +51 -0
  21. package/dist/lib/time.d.ts +38 -0
  22. package/dist/lib/toolResult.d.ts +7 -0
  23. package/dist/lib/urls.d.ts +2 -0
  24. package/dist/lib/zones.d.ts +24 -0
  25. package/dist/registerTools.d.ts +40 -0
  26. package/dist/registerTools.js +401 -0
  27. package/dist/runtimeConfig.d.ts +37 -0
  28. package/dist/runtimeConfig.js +93 -0
  29. package/dist/server.d.ts +4 -0
  30. package/dist/server.js +3 -318
  31. package/dist/tools/createCampaign.d.ts +25 -0
  32. package/dist/tools/createTeamMember.d.ts +26 -0
  33. package/dist/tools/createZone.d.ts +22 -0
  34. package/dist/tools/cutZones.d.ts +29 -0
  35. package/dist/tools/deletePayoutRule.d.ts +16 -0
  36. package/dist/tools/getCampaign.d.ts +12 -0
  37. package/dist/tools/getCampaign.js +150 -0
  38. package/dist/tools/getStats.d.ts +40 -0
  39. package/dist/tools/getStats.js +37 -2
  40. package/dist/tools/getTrackingLink.d.ts +23 -0
  41. package/dist/tools/getZoneUrl.d.ts +21 -0
  42. package/dist/tools/listCampaigns.d.ts +23 -0
  43. package/dist/tools/listConversions.d.ts +29 -0
  44. package/dist/tools/listPayoutRules.d.ts +12 -0
  45. package/dist/tools/listSubLabels.d.ts +5 -0
  46. package/dist/tools/listTargetingRules.d.ts +12 -0
  47. package/dist/tools/listTargetingTypes.d.ts +5 -0
  48. package/dist/tools/listTeam.d.ts +14 -0
  49. package/dist/tools/listZones.d.ts +23 -0
  50. package/dist/tools/removeTargetingRule.d.ts +21 -0
  51. package/dist/tools/setCampaignStatus.d.ts +18 -0
  52. package/dist/tools/setPayoutGoal.d.ts +16 -0
  53. package/dist/tools/setPayoutRule.d.ts +18 -0
  54. package/dist/tools/setSubLabels.d.ts +22 -0
  55. package/dist/tools/setTargetingRule.d.ts +21 -0
  56. package/dist/tools/updateCampaign.d.ts +35 -0
  57. package/dist/tools/updateZone.d.ts +25 -0
  58. package/dist/tools/whoami.d.ts +6 -0
  59. package/dist/types.d.ts +210 -0
  60. package/dist/types.js +1 -0
  61. package/dist/version.d.ts +7 -0
  62. package/dist/version.js +8 -0
  63. 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
@@ -9,33 +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_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
20
- | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. |
21
- | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
22
- | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
23
- | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
24
- | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
25
- | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. |
26
- | `get_tracking_link` | The `/track/click` link for an existing campaign + zone straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. |
27
- | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
28
- | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
29
- | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
30
- | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
31
- | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
32
- | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
33
- | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
34
- | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
35
- | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
36
- | `list_sub_labels` | List tenant display names for sub1–sub5. |
37
- | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
38
- | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. |
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 settingsrotates 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. |
39
40
 
40
41
  ### Which URL do I give the network?
41
42
 
@@ -64,22 +65,46 @@ URL. Both URL types also require an active zone. Geo whitelist is enforced in `/
64
65
  only — the direct tracking link is **not** geo-gated, but it still requires an active,
65
66
  currently serviceable campaign.
66
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
+
67
89
  ## Configuration
68
90
 
69
91
  All config comes from environment variables (never hard-coded):
70
92
 
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`). |
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. |
78
101
 
79
102
  See [`.env.example`](.env.example).
80
103
 
81
104
  ## Install
82
105
 
106
+ Requires Node.js 22.13 or newer.
107
+
83
108
  ### From npm (recommended)
84
109
 
85
110
  No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
@@ -164,6 +189,8 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
164
189
 
165
190
  > **list paused campaigns** → `list_campaigns(status: "paused")`
166
191
  >
192
+ > **show me everything about campaign 42** → `get_campaign(campaign_id: 42)`
193
+ >
167
194
  > **show zones** → `list_zones()`
168
195
  >
169
196
  > **who's on the team?** → `list_team()`
@@ -172,6 +199,10 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
172
199
  >
173
200
  > **stats for today by sub1** → `get_stats(group_by: "sub1")`
174
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
+ >
175
206
  > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
176
207
  >
177
208
  > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
@@ -211,7 +242,12 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
211
242
  ## Notes & limits
212
243
 
213
244
  - **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
214
- calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` filters.
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).
215
251
  - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
216
252
  cost data imported for the slice.
217
253
  - List endpoints have **no server-side name search** — `name_contains` filters the
@@ -257,6 +293,44 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
257
293
  startup — so it could not use a tenant it just created. Sign up in the dashboard,
258
294
  then point a server instance at the new namespace.
259
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
+
260
334
  ## Development
261
335
 
262
336
  ```bash
@@ -273,12 +347,15 @@ npm run dev # watch mode
273
347
 
274
348
  - No secrets in the repo; credentials come from the environment at runtime.
275
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.
276
352
  - stdout is the JSON-RPC channel — all logs go to stderr.
277
353
  - `list_team` redacts API tokens.
278
354
  - All mutations (including creates) follow **show → confirm → apply**.
279
355
  - Create a **dedicated, least-privilege API key** for the MCP rather than reusing an
280
- owner key, and give it an expiry — affset's RBAC roles (owner/manager/publisher/
281
- advertiser) apply to MCP tool calls exactly as they do to the dashboard.
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.
282
359
  - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
283
360
  floating `main` spec can run newer repository code the next time `npx` resolves it.
284
361
 
@@ -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>;