@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.
- package/.env.example +5 -0
- package/README.md +114 -37
- package/dist/client.d.ts +43 -0
- package/dist/client.js +15 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +15 -54
- package/dist/core.d.ts +18 -0
- package/dist/core.js +18 -0
- package/dist/docs.d.ts +39 -0
- package/dist/docs.js +105 -0
- package/dist/index.d.ts +2 -0
- package/dist/lib/format.d.ts +46 -0
- package/dist/lib/format.js +3 -0
- package/dist/lib/integrationUrls.d.ts +57 -0
- package/dist/lib/linkArgs.d.ts +27 -0
- package/dist/lib/patch.d.ts +10 -0
- package/dist/lib/payoutRules.d.ts +38 -0
- package/dist/lib/readBody.d.ts +17 -0
- package/dist/lib/readBody.js +69 -0
- package/dist/lib/targeting.d.ts +51 -0
- package/dist/lib/time.d.ts +38 -0
- package/dist/lib/toolResult.d.ts +7 -0
- package/dist/lib/urls.d.ts +2 -0
- package/dist/lib/zones.d.ts +24 -0
- package/dist/registerTools.d.ts +40 -0
- package/dist/registerTools.js +401 -0
- package/dist/runtimeConfig.d.ts +37 -0
- package/dist/runtimeConfig.js +93 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +3 -318
- package/dist/tools/createCampaign.d.ts +25 -0
- package/dist/tools/createTeamMember.d.ts +26 -0
- package/dist/tools/createZone.d.ts +22 -0
- package/dist/tools/cutZones.d.ts +29 -0
- package/dist/tools/deletePayoutRule.d.ts +16 -0
- package/dist/tools/getCampaign.d.ts +12 -0
- package/dist/tools/getCampaign.js +150 -0
- package/dist/tools/getStats.d.ts +40 -0
- package/dist/tools/getStats.js +37 -2
- package/dist/tools/getTrackingLink.d.ts +23 -0
- package/dist/tools/getZoneUrl.d.ts +21 -0
- package/dist/tools/listCampaigns.d.ts +23 -0
- package/dist/tools/listConversions.d.ts +29 -0
- package/dist/tools/listPayoutRules.d.ts +12 -0
- package/dist/tools/listSubLabels.d.ts +5 -0
- package/dist/tools/listTargetingRules.d.ts +12 -0
- package/dist/tools/listTargetingTypes.d.ts +5 -0
- package/dist/tools/listTeam.d.ts +14 -0
- package/dist/tools/listZones.d.ts +23 -0
- package/dist/tools/removeTargetingRule.d.ts +21 -0
- package/dist/tools/setCampaignStatus.d.ts +18 -0
- package/dist/tools/setPayoutGoal.d.ts +16 -0
- package/dist/tools/setPayoutRule.d.ts +18 -0
- package/dist/tools/setSubLabels.d.ts +22 -0
- package/dist/tools/setTargetingRule.d.ts +21 -0
- package/dist/tools/updateCampaign.d.ts +35 -0
- package/dist/tools/updateZone.d.ts +25 -0
- package/dist/tools/whoami.d.ts +6 -0
- package/dist/types.d.ts +210 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +8 -0
- package/package.json +15 -2
package/.env.example
CHANGED
|
@@ -12,6 +12,11 @@ AFFSET_NAMESPACE=your-namespace
|
|
|
12
12
|
# Optional. Per-request HTTP timeout in milliseconds (1000–300000; default 30000).
|
|
13
13
|
# AFFSET_REQUEST_TIMEOUT_MS=30000
|
|
14
14
|
|
|
15
|
+
# Optional. Origin the API-reference documentation resources are fetched from
|
|
16
|
+
# (origin only, no path). Defaults to the affset marketing site; override to
|
|
17
|
+
# point at a staging docs host. Fetched anonymously — no API key is sent here.
|
|
18
|
+
# AFFSET_DOCS_URL=https://affset.com
|
|
19
|
+
|
|
15
20
|
# Optional. Set to true to register only read-only tools (no create/update/delete/
|
|
16
21
|
# cut) — recommended for stats/reporting sessions, especially with an MCP client
|
|
17
22
|
# that auto-approves tool calls. See README Security section. Default false.
|
package/README.md
CHANGED
|
@@ -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
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
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. |
|
|
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`
|
|
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/
|
|
281
|
-
|
|
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
|
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Config } from "./runtimeConfig.js";
|
|
2
|
+
/** Thrown when the affset API returns a non-2xx response. */
|
|
3
|
+
export declare class AffsetApiError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
/** Parsed JSON body when present (e.g. plan-limit details). */
|
|
6
|
+
readonly body?: unknown | undefined;
|
|
7
|
+
constructor(status: number, message: string,
|
|
8
|
+
/** Parsed JSON body when present (e.g. plan-limit details). */
|
|
9
|
+
body?: unknown | undefined);
|
|
10
|
+
}
|
|
11
|
+
type Query = Record<string, string | number | undefined>;
|
|
12
|
+
/**
|
|
13
|
+
* Thin typed HTTP client over the affset tenant API. Injects the Bearer token
|
|
14
|
+
* and `X-Namespace` header on every request and normalises error handling.
|
|
15
|
+
*/
|
|
16
|
+
export declare class AffsetClient {
|
|
17
|
+
private readonly config;
|
|
18
|
+
constructor(config: Config);
|
|
19
|
+
/** Resolved once per process; the tenant timezone changes about never. */
|
|
20
|
+
private tenantTimezone?;
|
|
21
|
+
/**
|
|
22
|
+
* The tenant's timezone (IANA name). Date ranges must be resolved in it, not
|
|
23
|
+
* in the host machine's zone, so that a requested window lines up with the
|
|
24
|
+
* date buckets `/api/stats?group_by=date` returns.
|
|
25
|
+
*
|
|
26
|
+
* Falls back to UTC if the setting cannot be read — a stats query with a
|
|
27
|
+
* slightly-off window beats failing the whole call.
|
|
28
|
+
*/
|
|
29
|
+
getTenantTimezone(): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Tenant timezone for a write whose meaning depends on local midnight. Unlike
|
|
32
|
+
* stats reads, silently falling back to UTC here could change a schedule, so
|
|
33
|
+
* callers get the underlying API error and must stop the mutation.
|
|
34
|
+
*/
|
|
35
|
+
getRequiredTenantTimezone(): Promise<string>;
|
|
36
|
+
private readTenantTimezone;
|
|
37
|
+
get<T>(path: string, query?: Query): Promise<T>;
|
|
38
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
39
|
+
put<T>(path: string, body: unknown): Promise<T>;
|
|
40
|
+
delete<T>(path: string, query?: Query): Promise<T>;
|
|
41
|
+
private request;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readResponseText, ResponseTooLargeError } from "./lib/readBody.js";
|
|
2
|
+
import { normalizeRuntimeConfig } from "./runtimeConfig.js";
|
|
1
3
|
/** Thrown when the affset API returns a non-2xx response. */
|
|
2
4
|
export class AffsetApiError extends Error {
|
|
3
5
|
status;
|
|
@@ -11,6 +13,8 @@ export class AffsetApiError extends Error {
|
|
|
11
13
|
this.name = "AffsetApiError";
|
|
12
14
|
}
|
|
13
15
|
}
|
|
16
|
+
/** Bound upstream memory use before JSON parsing or rendering into model context. */
|
|
17
|
+
const MAX_API_RESPONSE_BYTES = 5_000_000;
|
|
14
18
|
/**
|
|
15
19
|
* Thin typed HTTP client over the affset tenant API. Injects the Bearer token
|
|
16
20
|
* and `X-Namespace` header on every request and normalises error handling.
|
|
@@ -18,7 +22,7 @@ export class AffsetApiError extends Error {
|
|
|
18
22
|
export class AffsetClient {
|
|
19
23
|
config;
|
|
20
24
|
constructor(config) {
|
|
21
|
-
this.config = config;
|
|
25
|
+
this.config = normalizeRuntimeConfig(config);
|
|
22
26
|
}
|
|
23
27
|
/** Resolved once per process; the tenant timezone changes about never. */
|
|
24
28
|
tenantTimezone;
|
|
@@ -100,7 +104,16 @@ export class AffsetClient {
|
|
|
100
104
|
}
|
|
101
105
|
throw new AffsetApiError(0, `Network error calling ${method} ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
102
106
|
}
|
|
103
|
-
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
text = await readResponseText(res, MAX_API_RESPONSE_BYTES);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof ResponseTooLargeError) {
|
|
113
|
+
throw new AffsetApiError(res.ok ? 0 : res.status, `Response too large calling ${method} ${path}: ${err.message}.`);
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
104
117
|
let data = null;
|
|
105
118
|
if (text) {
|
|
106
119
|
try {
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Config } from "./runtimeConfig.js";
|
|
2
|
+
export type { Config } from "./runtimeConfig.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read and validate config from the environment. Throws with an actionable
|
|
5
|
+
* message listing every missing variable — surfaced to the user at startup.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
package/dist/config.js
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
|
+
import { MAX_REQUEST_TIMEOUT_MS, MIN_REQUEST_TIMEOUT_MS, parseOriginUrl, validateApiKey, validateNamespace, } from "./runtimeConfig.js";
|
|
1
2
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
2
|
-
|
|
3
|
+
/** Where the docs feeds live when AFFSET_DOCS_URL is not set. */
|
|
4
|
+
const DEFAULT_DOCS_URL = "https://affset.com";
|
|
3
5
|
const TRUTHY = new Set(["1", "true", "yes", "on"]);
|
|
4
6
|
const FALSY = new Set(["0", "false", "no", "off"]);
|
|
5
|
-
/**
|
|
6
|
-
* Same rules as lite-adserver signup: lowercase alnum + hyphens, 3–63 chars,
|
|
7
|
-
* no leading/trailing hyphen, no consecutive hyphens. Keeps whoami's
|
|
8
|
-
* `https://{namespace}.affset.com` URL from embedding attacker-chosen junk if
|
|
9
|
-
* someone pastes a weird AFFSET_NAMESPACE into their MCP client config.
|
|
10
|
-
*/
|
|
11
|
-
const NAMESPACE_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
12
|
-
/** Loopback hosts allowed to stay on plain http — everything else must be https. */
|
|
13
|
-
function isLoopbackHost(hostname) {
|
|
14
|
-
return (hostname === "localhost" ||
|
|
15
|
-
hostname === "127.0.0.1" ||
|
|
16
|
-
hostname === "::1" ||
|
|
17
|
-
hostname.endsWith(".localhost"));
|
|
18
|
-
}
|
|
19
|
-
function namespaceError(namespace) {
|
|
20
|
-
if (namespace.length < 3 || namespace.length > 63) {
|
|
21
|
-
return "AFFSET_NAMESPACE must be 3–63 characters.";
|
|
22
|
-
}
|
|
23
|
-
if (!NAMESPACE_RE.test(namespace) || namespace.includes("--")) {
|
|
24
|
-
return ("AFFSET_NAMESPACE must be lowercase letters, numbers, and hyphens only " +
|
|
25
|
-
"(no leading/trailing or consecutive hyphens).");
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
7
|
/**
|
|
30
8
|
* Read and validate config from the environment. Throws with an actionable
|
|
31
9
|
* message listing every missing variable — surfaced to the user at startup.
|
|
@@ -45,38 +23,20 @@ export function loadConfig(env = process.env) {
|
|
|
45
23
|
throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. ` +
|
|
46
24
|
`See .env.example for the expected values.`);
|
|
47
25
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
catch {
|
|
57
|
-
throw new Error(`AFFSET_BASE_URL is not a valid URL: ${baseUrl}`);
|
|
58
|
-
}
|
|
59
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
60
|
-
throw new Error(`AFFSET_BASE_URL must be http(s), got ${parsed.protocol}`);
|
|
61
|
-
}
|
|
62
|
-
if (parsed.username || parsed.password) {
|
|
63
|
-
throw new Error("AFFSET_BASE_URL must not contain embedded credentials.");
|
|
64
|
-
}
|
|
65
|
-
if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) {
|
|
66
|
-
throw new Error("AFFSET_BASE_URL must be an origin only (scheme, host, and optional port; no path, query, or fragment).");
|
|
67
|
-
}
|
|
68
|
-
// Plain http sends the Bearer API key in cleartext. Only loopback (local dev /
|
|
69
|
-
// wrangler dev) is exempt — anything else must be https.
|
|
70
|
-
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
71
|
-
throw new Error(`AFFSET_BASE_URL must be https for a non-local host (got http://${parsed.hostname}) — ` +
|
|
72
|
-
`plain http would send the API key in cleartext.`);
|
|
73
|
-
}
|
|
26
|
+
validateNamespace(namespace, "AFFSET_NAMESPACE");
|
|
27
|
+
const parsedApiKey = validateApiKey(apiKey, "AFFSET_API_KEY");
|
|
28
|
+
// Plain http on AFFSET_BASE_URL sends the Bearer API key in cleartext; the
|
|
29
|
+
// helper refuses it for non-loopback hosts.
|
|
30
|
+
const parsed = parseOriginUrl(baseUrl, "AFFSET_BASE_URL", true);
|
|
31
|
+
// Optional — the docs feeds are public, so this only sets where to fetch them.
|
|
32
|
+
const docsRaw = env.AFFSET_DOCS_URL?.trim();
|
|
33
|
+
const docsBaseUrl = parseOriginUrl(docsRaw || DEFAULT_DOCS_URL, "AFFSET_DOCS_URL").origin;
|
|
74
34
|
const timeoutRaw = env.AFFSET_REQUEST_TIMEOUT_MS?.trim();
|
|
75
35
|
let requestTimeoutMs = DEFAULT_TIMEOUT_MS;
|
|
76
36
|
if (timeoutRaw) {
|
|
77
37
|
const n = Number(timeoutRaw);
|
|
78
|
-
if (!Number.isSafeInteger(n) || n <
|
|
79
|
-
throw new Error(`AFFSET_REQUEST_TIMEOUT_MS must be an integer between
|
|
38
|
+
if (!Number.isSafeInteger(n) || n < MIN_REQUEST_TIMEOUT_MS || n > MAX_REQUEST_TIMEOUT_MS) {
|
|
39
|
+
throw new Error(`AFFSET_REQUEST_TIMEOUT_MS must be an integer between ${MIN_REQUEST_TIMEOUT_MS} and ${MAX_REQUEST_TIMEOUT_MS}.`);
|
|
80
40
|
}
|
|
81
41
|
requestTimeoutMs = n;
|
|
82
42
|
}
|
|
@@ -91,7 +51,8 @@ export function loadConfig(env = process.env) {
|
|
|
91
51
|
}
|
|
92
52
|
return {
|
|
93
53
|
baseUrl: parsed.origin,
|
|
94
|
-
|
|
54
|
+
docsBaseUrl,
|
|
55
|
+
apiKey: parsedApiKey,
|
|
95
56
|
namespace: namespace,
|
|
96
57
|
requestTimeoutMs,
|
|
97
58
|
readOnly,
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic library surface of @affset/mcp (REMOTE-MCP-PRD.md §5.6).
|
|
3
|
+
*
|
|
4
|
+
* Everything exported here runs on any fetch-capable runtime (Node ≥22.13,
|
|
5
|
+
* Cloudflare Workers) — no `process.env`, no `node:` imports. The stdio
|
|
6
|
+
* entrypoint (`dist/index.js`, the `affset-mcp` bin) layers env-var loading on
|
|
7
|
+
* top of this; the remote MCP gateway imports this surface directly and
|
|
8
|
+
* supplies per-grant credentials instead.
|
|
9
|
+
*
|
|
10
|
+
* `loadConfig` (env-var parsing) is deliberately NOT exported: it is the
|
|
11
|
+
* stdio entrypoint's concern, and its signature drags Node types into
|
|
12
|
+
* consumers.
|
|
13
|
+
*/
|
|
14
|
+
export { registerAffsetTools, type AffsetToolServer, type RegisterAffsetToolsOptions, type ToolCallEvent, } from "./registerTools.js";
|
|
15
|
+
export type { Config } from "./runtimeConfig.js";
|
|
16
|
+
export { AffsetClient, AffsetApiError } from "./client.js";
|
|
17
|
+
export { DOCS_FEEDS, fetchDocsFeed, DocsFetchError, type DocsFeed } from "./docs.js";
|
|
18
|
+
export { VERSION } from "./version.js";
|
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>;
|