@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.
- package/.env.example +18 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/client.js +131 -0
- package/dist/config.js +100 -0
- package/dist/index.js +21 -0
- package/dist/lib/format.js +148 -0
- package/dist/lib/integrationUrls.js +128 -0
- package/dist/lib/linkArgs.js +53 -0
- package/dist/lib/patch.js +23 -0
- package/dist/lib/payoutRules.js +46 -0
- package/dist/lib/targeting.js +246 -0
- package/dist/lib/time.js +234 -0
- package/dist/lib/toolResult.js +35 -0
- package/dist/lib/urls.js +15 -0
- package/dist/lib/zones.js +83 -0
- package/dist/server.js +316 -0
- package/dist/tools/createCampaign.js +211 -0
- package/dist/tools/createZone.js +117 -0
- package/dist/tools/cutZones.js +224 -0
- package/dist/tools/deletePayoutRule.js +70 -0
- package/dist/tools/getStats.js +72 -0
- package/dist/tools/getTrackingLink.js +119 -0
- package/dist/tools/getZoneUrl.js +94 -0
- package/dist/tools/listCampaigns.js +81 -0
- package/dist/tools/listConversions.js +237 -0
- package/dist/tools/listPayoutRules.js +72 -0
- package/dist/tools/listSubLabels.js +31 -0
- package/dist/tools/listTargetingRules.js +69 -0
- package/dist/tools/listTargetingTypes.js +39 -0
- package/dist/tools/listTeam.js +57 -0
- package/dist/tools/listZones.js +70 -0
- package/dist/tools/removeTargetingRule.js +109 -0
- package/dist/tools/setCampaignStatus.js +32 -0
- package/dist/tools/setPayoutGoal.js +69 -0
- package/dist/tools/setPayoutRule.js +119 -0
- package/dist/tools/setSubLabels.js +102 -0
- package/dist/tools/setTargetingRule.js +125 -0
- package/dist/tools/updateCampaign.js +218 -0
- package/dist/tools/updateZone.js +118 -0
- package/dist/tools/whoami.js +42 -0
- package/dist/types.js +18 -0
- package/package.json +70 -0
package/dist/lib/time.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date-range resolution for stats queries.
|
|
3
|
+
*
|
|
4
|
+
* Every boundary here is computed in the TENANT's timezone, not the timezone of
|
|
5
|
+
* the machine running this server. The API buckets `group_by=date` by the tenant
|
|
6
|
+
* timezone, so resolving "today" against the operator's laptop clock would ask
|
|
7
|
+
* for a window that straddles two of the buckets it gets back — a single "today"
|
|
8
|
+
* arriving as two partial rows. Callers fetch the tenant timezone (see
|
|
9
|
+
* AffsetClient#getTenantTimezone) and pass it in.
|
|
10
|
+
*/
|
|
11
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
12
|
+
const ISO_TIMESTAMP = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})$/i;
|
|
13
|
+
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
14
|
+
export const RANGE_PRESETS = [
|
|
15
|
+
"today",
|
|
16
|
+
"yesterday",
|
|
17
|
+
"last_7_days",
|
|
18
|
+
"last_30_days",
|
|
19
|
+
"this_month",
|
|
20
|
+
];
|
|
21
|
+
function parseCalendarDay(value) {
|
|
22
|
+
const [y, month, d] = value.split("-").map(Number);
|
|
23
|
+
const m = month - 1;
|
|
24
|
+
const check = new Date(Date.UTC(y, m, d));
|
|
25
|
+
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== m || check.getUTCDate() !== d) {
|
|
26
|
+
throw new Error(`Invalid date "${value}". Use a real YYYY-MM-DD calendar date.`);
|
|
27
|
+
}
|
|
28
|
+
return { y, m, d };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* UTC offset in ms of `timeZone` at a specific instant. Resolved per instant
|
|
32
|
+
* rather than per day because a zone's offset changes partway through a DST
|
|
33
|
+
* transition day.
|
|
34
|
+
*/
|
|
35
|
+
function offsetMsAt(utcMs, timeZone) {
|
|
36
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
37
|
+
timeZone,
|
|
38
|
+
year: "numeric",
|
|
39
|
+
month: "2-digit",
|
|
40
|
+
day: "2-digit",
|
|
41
|
+
hour: "2-digit",
|
|
42
|
+
minute: "2-digit",
|
|
43
|
+
second: "2-digit",
|
|
44
|
+
hour12: false,
|
|
45
|
+
}).formatToParts(new Date(utcMs));
|
|
46
|
+
const get = (name) => parseInt(parts.find((p) => p.type === name)?.value ?? "0", 10);
|
|
47
|
+
// en-CA with hour12:false renders midnight as "24"; normalise it.
|
|
48
|
+
const hour = get("hour") % 24;
|
|
49
|
+
const localAsUtcMs = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
|
|
50
|
+
return localAsUtcMs - Math.floor(utcMs / 1000) * 1000;
|
|
51
|
+
}
|
|
52
|
+
/** Which calendar day an instant falls on, in the given timezone. */
|
|
53
|
+
function calendarDayIn(utcMs, timeZone) {
|
|
54
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
55
|
+
timeZone,
|
|
56
|
+
year: "numeric",
|
|
57
|
+
month: "2-digit",
|
|
58
|
+
day: "2-digit",
|
|
59
|
+
}).formatToParts(new Date(utcMs));
|
|
60
|
+
const get = (name) => parseInt(parts.find((p) => p.type === name)?.value ?? "0", 10);
|
|
61
|
+
return { y: get("year"), m: get("month") - 1, d: get("day") };
|
|
62
|
+
}
|
|
63
|
+
/** Shift a calendar day by whole days. Zone-free arithmetic; handles rollover. */
|
|
64
|
+
function addDays(day, n) {
|
|
65
|
+
const t = new Date(Date.UTC(day.y, day.m, day.d + n));
|
|
66
|
+
return { y: t.getUTCFullYear(), m: t.getUTCMonth(), d: t.getUTCDate() };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Epoch ms for 00:00:00.000 on a calendar day in a timezone.
|
|
70
|
+
*
|
|
71
|
+
* Two passes: the offset we need is the one in force at local midnight, and that
|
|
72
|
+
* is not knowable until the instant is. A fixed-hour guess is wrong on DST
|
|
73
|
+
* transition days.
|
|
74
|
+
*/
|
|
75
|
+
function startOfDayMs(day, timeZone) {
|
|
76
|
+
const wallClock = Date.UTC(day.y, day.m, day.d, 0, 0, 0, 0);
|
|
77
|
+
const firstGuess = wallClock - offsetMsAt(wallClock, timeZone);
|
|
78
|
+
return wallClock - offsetMsAt(firstGuess, timeZone);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Epoch ms for 23:59:59.999 on a calendar day in a timezone. Derived from the
|
|
82
|
+
* next day's start so 23h and 25h days end on the right instant.
|
|
83
|
+
*/
|
|
84
|
+
function endOfDayMs(day, timeZone) {
|
|
85
|
+
return startOfDayMs(addDays(day, 1), timeZone) - 1;
|
|
86
|
+
}
|
|
87
|
+
/** Format epoch ms as a YYYY-MM-DD calendar day in the tenant timezone. */
|
|
88
|
+
function fmtDay(ms, timeZone) {
|
|
89
|
+
const { y, m, d } = calendarDayIn(ms, timeZone);
|
|
90
|
+
return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse an explicit bound: epoch-ms string, YYYY-MM-DD calendar day, or ISO
|
|
94
|
+
* timestamp with an explicit offset. Date-only strings are read as calendar
|
|
95
|
+
* days in the tenant timezone — matching the presets and the API's date buckets
|
|
96
|
+
* — and for `to` mean end-of-day so a full calendar day is included.
|
|
97
|
+
*/
|
|
98
|
+
function parseBound(value, role, timeZone) {
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const trimmed = value.trim();
|
|
102
|
+
if (/^\d+$/.test(trimmed))
|
|
103
|
+
return parseEpochMs(Number(trimmed), value);
|
|
104
|
+
if (DATE_ONLY.test(trimmed)) {
|
|
105
|
+
const day = parseCalendarDay(trimmed);
|
|
106
|
+
return role === "to" ? endOfDayMs(day, timeZone) : startOfDayMs(day, timeZone);
|
|
107
|
+
}
|
|
108
|
+
return parseIsoTimestamp(trimmed);
|
|
109
|
+
}
|
|
110
|
+
function parseEpochMs(value, original) {
|
|
111
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_DATE_MS) {
|
|
112
|
+
throw new Error(`Invalid date "${original}". Epoch milliseconds must be a positive safe integer.`);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function parseIsoTimestamp(value) {
|
|
117
|
+
const match = ISO_TIMESTAMP.exec(value);
|
|
118
|
+
if (!match) {
|
|
119
|
+
throw new Error(`Invalid date "${value}". Use YYYY-MM-DD, epoch ms, or an ISO timestamp with Z/UTC offset.`);
|
|
120
|
+
}
|
|
121
|
+
parseCalendarDay(match[1]);
|
|
122
|
+
const hour = Number(match[2]);
|
|
123
|
+
const minute = Number(match[3]);
|
|
124
|
+
const second = Number(match[4] ?? 0);
|
|
125
|
+
const offset = match[6];
|
|
126
|
+
const offsetHour = offset === "Z" || offset === "z" ? 0 : Number(offset.slice(1, 3));
|
|
127
|
+
const offsetMinute = offset === "Z" || offset === "z" ? 0 : Number(offset.slice(4, 6));
|
|
128
|
+
if (hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
|
|
129
|
+
throw new Error(`Invalid date "${value}". Timestamp contains an out-of-range time.`);
|
|
130
|
+
}
|
|
131
|
+
const parsed = Date.parse(value);
|
|
132
|
+
if (Number.isNaN(parsed)) {
|
|
133
|
+
throw new Error(`Invalid date "${value}". Use a valid ISO timestamp with Z/UTC offset.`);
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Parse one campaign schedule boundary. Date-only values use the tenant's
|
|
139
|
+
* timezone; explicit ISO timestamps and epoch milliseconds remain exact.
|
|
140
|
+
*/
|
|
141
|
+
export function parseCampaignDateBound(value, role, timeZone) {
|
|
142
|
+
if (value === null)
|
|
143
|
+
return null;
|
|
144
|
+
if (typeof value === "number")
|
|
145
|
+
return parseEpochMs(value, value);
|
|
146
|
+
const trimmed = value.trim();
|
|
147
|
+
if (/^\d+$/.test(trimmed))
|
|
148
|
+
return parseEpochMs(Number(trimmed), value);
|
|
149
|
+
if (DATE_ONLY.test(trimmed)) {
|
|
150
|
+
const day = parseCalendarDay(trimmed);
|
|
151
|
+
return role === "end" ? endOfDayMs(day, timeZone) : startOfDayMs(day, timeZone);
|
|
152
|
+
}
|
|
153
|
+
return parseIsoTimestamp(trimmed);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a range from an optional preset and/or explicit from/to bounds.
|
|
157
|
+
* Explicit bounds win over the preset. All day boundaries are in `timeZone`
|
|
158
|
+
* (the tenant's), so the window lines up with the API's date buckets.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveRange(preset, from, to, timeZone) {
|
|
161
|
+
const explicitFrom = parseBound(from, "from", timeZone);
|
|
162
|
+
const explicitTo = parseBound(to, "to", timeZone);
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const today = calendarDayIn(now, timeZone);
|
|
165
|
+
if (explicitFrom !== undefined || explicitTo !== undefined) {
|
|
166
|
+
const f = explicitFrom ?? startOfDayMs(today, timeZone);
|
|
167
|
+
const t = explicitTo ?? now;
|
|
168
|
+
if (f > t) {
|
|
169
|
+
throw new Error(`Invalid range: from (${fmtDay(f, timeZone)}) is after to (${fmtDay(t, timeZone)}).`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
from: f,
|
|
173
|
+
to: t,
|
|
174
|
+
label: `${fmtDay(f, timeZone)} → ${fmtDay(t, timeZone)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const startOfToday = startOfDayMs(today, timeZone);
|
|
178
|
+
switch (preset ?? "today") {
|
|
179
|
+
case "yesterday": {
|
|
180
|
+
const yesterday = addDays(today, -1);
|
|
181
|
+
return {
|
|
182
|
+
from: startOfDayMs(yesterday, timeZone),
|
|
183
|
+
to: startOfToday - 1,
|
|
184
|
+
label: "yesterday",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case "last_7_days":
|
|
188
|
+
// Calendar arithmetic, not `today - 6 * 86400000`: subtracting fixed
|
|
189
|
+
// milliseconds slips an hour across a DST change and lands mid-day.
|
|
190
|
+
return {
|
|
191
|
+
from: startOfDayMs(addDays(today, -6), timeZone),
|
|
192
|
+
to: now,
|
|
193
|
+
label: "last 7 days",
|
|
194
|
+
};
|
|
195
|
+
case "last_30_days":
|
|
196
|
+
return {
|
|
197
|
+
from: startOfDayMs(addDays(today, -29), timeZone),
|
|
198
|
+
to: now,
|
|
199
|
+
label: "last 30 days",
|
|
200
|
+
};
|
|
201
|
+
case "this_month":
|
|
202
|
+
return {
|
|
203
|
+
from: startOfDayMs({ y: today.y, m: today.m, d: 1 }, timeZone),
|
|
204
|
+
to: now,
|
|
205
|
+
label: "this month",
|
|
206
|
+
};
|
|
207
|
+
case "today":
|
|
208
|
+
default:
|
|
209
|
+
return { from: startOfToday, to: now, label: "today" };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Format an instant as `YYYY-MM-DD HH:mm` in the tenant timezone. Row timestamps
|
|
214
|
+
* have to agree with the date buckets stats are read in: a conversion at 23:30
|
|
215
|
+
* tenant-local rendered in UTC lands on the next day's row and reads as a
|
|
216
|
+
* missing conversion.
|
|
217
|
+
*/
|
|
218
|
+
export function formatInstant(ms, timeZone) {
|
|
219
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
220
|
+
timeZone,
|
|
221
|
+
year: "numeric",
|
|
222
|
+
month: "2-digit",
|
|
223
|
+
day: "2-digit",
|
|
224
|
+
hour: "2-digit",
|
|
225
|
+
minute: "2-digit",
|
|
226
|
+
hour12: false,
|
|
227
|
+
}).formatToParts(new Date(ms));
|
|
228
|
+
const get = (name) => parseInt(parts.find((p) => p.type === name)?.value ?? "0", 10);
|
|
229
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
230
|
+
// en-CA with hour12:false renders midnight as "24"; normalise it.
|
|
231
|
+
return (`${get("year")}-${pad(get("month"))}-${pad(get("day"))} ` +
|
|
232
|
+
`${pad(get("hour") % 24)}:${pad(get("minute"))}`);
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=time.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AffsetApiError } from "../client.js";
|
|
2
|
+
/** Build a successful text tool result. */
|
|
3
|
+
export function textResult(body) {
|
|
4
|
+
return { content: [{ type: "text", text: body }] };
|
|
5
|
+
}
|
|
6
|
+
/** Build an error tool result (isError: true). */
|
|
7
|
+
export function errorResult(err) {
|
|
8
|
+
return { content: [{ type: "text", text: formatError(err) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
/** Build an error tool result from a plain string. */
|
|
11
|
+
export function textError(body) {
|
|
12
|
+
return { content: [{ type: "text", text: body }], isError: true };
|
|
13
|
+
}
|
|
14
|
+
function formatError(err) {
|
|
15
|
+
if (!(err instanceof AffsetApiError)) {
|
|
16
|
+
return err instanceof Error ? err.message : String(err);
|
|
17
|
+
}
|
|
18
|
+
let message = `affset API error (${err.status}): ${err.message}`;
|
|
19
|
+
const body = err.body;
|
|
20
|
+
if (body && typeof body === "object" && "code" in body) {
|
|
21
|
+
const b = body;
|
|
22
|
+
if (b.code === "PLAN_LIMIT_REACHED" || b.code === "SUBSCRIPTION_REQUIRED") {
|
|
23
|
+
const bits = [
|
|
24
|
+
`code=${String(b.code)}`,
|
|
25
|
+
b.dimension != null ? `dimension=${String(b.dimension)}` : null,
|
|
26
|
+
b.current != null && b.limit != null ? `${b.current}/${b.limit}` : null,
|
|
27
|
+
b.min_plan_id != null ? `min_plan=${String(b.min_plan_id)}` : null,
|
|
28
|
+
b.plan_id != null ? `plan=${String(b.plan_id)}` : null,
|
|
29
|
+
].filter(Boolean);
|
|
30
|
+
message += ` (${bits.join(", ")})`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return message;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=toolResult.js.map
|
package/dist/lib/urls.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Validate http(s) URL; returns an error message or undefined when ok. */
|
|
2
|
+
export function httpUrlError(value, label) {
|
|
3
|
+
let parsed;
|
|
4
|
+
try {
|
|
5
|
+
parsed = new URL(value);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return `${label} is not a valid URL: ${value}`;
|
|
9
|
+
}
|
|
10
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
11
|
+
return `${label} must be http(s), got ${parsed.protocol}`;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=urls.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone lookup shared by every tool that needs a traffic source: campaign creation
|
|
3
|
+
* and both integration-URL tools all take an optional `zone_id` and auto-pick when
|
|
4
|
+
* the namespace has exactly one active zone.
|
|
5
|
+
*/
|
|
6
|
+
import { AffsetApiError } from "../client.js";
|
|
7
|
+
import { mdCell } from "./format.js";
|
|
8
|
+
const ZONE_CHOICES_LIMIT = 25;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the zone to build URLs against. An explicit id is fetched directly — the
|
|
11
|
+
* paginated list must not be the arbiter of whether a zone exists.
|
|
12
|
+
*/
|
|
13
|
+
export async function resolveZone(client, zoneId) {
|
|
14
|
+
if (zoneId !== undefined) {
|
|
15
|
+
const explicitId = zoneId.trim();
|
|
16
|
+
if (!explicitId)
|
|
17
|
+
return { error: "zone_id cannot be blank." };
|
|
18
|
+
try {
|
|
19
|
+
const zone = await client.get(`/api/zones/${encodeURIComponent(explicitId)}`);
|
|
20
|
+
if (zone.status !== "active") {
|
|
21
|
+
return {
|
|
22
|
+
zone,
|
|
23
|
+
inactiveWarning: `⚠️ Zone \`${zone.id}\` is **${zone.status}** — both /serve and direct ` +
|
|
24
|
+
"/track/click URLs return 404 until the zone is reactivated.",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { zone };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
31
|
+
return { error: `Zone \`${explicitId}\` not found in this namespace.` };
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const { zones: active, total } = await listActiveZones(client);
|
|
37
|
+
if (total === 1 && active.length === 1) {
|
|
38
|
+
return { zone: active[0] };
|
|
39
|
+
}
|
|
40
|
+
if (total === 0) {
|
|
41
|
+
return {
|
|
42
|
+
error: "No active zones in this namespace. Create a zone for the traffic source first " +
|
|
43
|
+
"(its postback_url is where conversions are reported back), then pass its id as zone_id.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
error: `Multiple active zones — pass zone_id to pick the traffic source:\n${zoneList(active, total)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** One filtered page is enough to auto-pick or show a bounded choice list. */
|
|
51
|
+
async function listActiveZones(client) {
|
|
52
|
+
const res = await client.get("/api/zones", {
|
|
53
|
+
status: "active",
|
|
54
|
+
limit: ZONE_CHOICES_LIMIT,
|
|
55
|
+
offset: 0,
|
|
56
|
+
sort: "name",
|
|
57
|
+
order: "asc",
|
|
58
|
+
});
|
|
59
|
+
const zones = (res.zones ?? []).filter((zone) => zone.status === "active");
|
|
60
|
+
return { zones, total: res.pagination?.total ?? zones.length };
|
|
61
|
+
}
|
|
62
|
+
function zoneList(zones, total) {
|
|
63
|
+
const lines = zones.map((z) => `- \`${z.id}\` — ${mdCell(z.name)}${z.postback_url ? "" : " (no postback URL)"}`);
|
|
64
|
+
if (total > zones.length) {
|
|
65
|
+
lines.push(`- …and ${total - zones.length} more`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The line every integration URL needs under it: without a postback URL on the zone,
|
|
71
|
+
* conversions never make it back to the traffic source and its optimizer stays blind.
|
|
72
|
+
*/
|
|
73
|
+
export function zonePostbackNote(zone) {
|
|
74
|
+
if (!zone.postback_url) {
|
|
75
|
+
return ("⚠️ no postback URL on this zone — set one with `update_zone` (include " +
|
|
76
|
+
"`{source_click_id}`) or the source will not see conversions");
|
|
77
|
+
}
|
|
78
|
+
if (!zone.postback_url.includes("{source_click_id}")) {
|
|
79
|
+
return `${mdCell(zone.postback_url)} — ⚠️ missing \`{source_click_id}\`, conversions cannot be attributed back`;
|
|
80
|
+
}
|
|
81
|
+
return "postback URL configured — conversions will be reported to the source";
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=zones.js.map
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { AffsetClient } from "./client.js";
|
|
3
|
+
import { getStats, getStatsInputSchema, GET_STATS_DESCRIPTION } from "./tools/getStats.js";
|
|
4
|
+
import { cutZones, cutZonesInputSchema, CUT_ZONES_DESCRIPTION } from "./tools/cutZones.js";
|
|
5
|
+
import { createCampaign, createCampaignInputSchema, CREATE_CAMPAIGN_DESCRIPTION, } from "./tools/createCampaign.js";
|
|
6
|
+
import { listCampaigns, listCampaignsInputSchema, LIST_CAMPAIGNS_DESCRIPTION, } from "./tools/listCampaigns.js";
|
|
7
|
+
import { listZones, listZonesInputSchema, LIST_ZONES_DESCRIPTION } from "./tools/listZones.js";
|
|
8
|
+
import { listTeam, listTeamInputSchema, LIST_TEAM_DESCRIPTION } from "./tools/listTeam.js";
|
|
9
|
+
import { createZone, createZoneInputSchema, CREATE_ZONE_DESCRIPTION } from "./tools/createZone.js";
|
|
10
|
+
import { getZoneUrl, getZoneUrlInputSchema, GET_ZONE_URL_DESCRIPTION } from "./tools/getZoneUrl.js";
|
|
11
|
+
import { getTrackingLink, getTrackingLinkInputSchema, GET_TRACKING_LINK_DESCRIPTION, } from "./tools/getTrackingLink.js";
|
|
12
|
+
import { updateZone, updateZoneInputSchema, UPDATE_ZONE_DESCRIPTION } from "./tools/updateZone.js";
|
|
13
|
+
import { updateCampaign, updateCampaignInputSchema, UPDATE_CAMPAIGN_DESCRIPTION, } from "./tools/updateCampaign.js";
|
|
14
|
+
import { setCampaignStatus, setCampaignStatusInputSchema, SET_CAMPAIGN_STATUS_DESCRIPTION, } from "./tools/setCampaignStatus.js";
|
|
15
|
+
import { listPayoutRules, listPayoutRulesInputSchema, LIST_PAYOUT_RULES_DESCRIPTION, } from "./tools/listPayoutRules.js";
|
|
16
|
+
import { setPayoutRule, setPayoutRuleInputSchema, SET_PAYOUT_RULE_DESCRIPTION, } from "./tools/setPayoutRule.js";
|
|
17
|
+
import { deletePayoutRule, deletePayoutRuleInputSchema, DELETE_PAYOUT_RULE_DESCRIPTION, } from "./tools/deletePayoutRule.js";
|
|
18
|
+
import { setPayoutGoal, setPayoutGoalInputSchema, SET_PAYOUT_GOAL_DESCRIPTION, } from "./tools/setPayoutGoal.js";
|
|
19
|
+
import { listTargetingTypes, listTargetingTypesInputSchema, LIST_TARGETING_TYPES_DESCRIPTION, } from "./tools/listTargetingTypes.js";
|
|
20
|
+
import { listTargetingRules, listTargetingRulesInputSchema, LIST_TARGETING_RULES_DESCRIPTION, } from "./tools/listTargetingRules.js";
|
|
21
|
+
import { setTargetingRule, setTargetingRuleInputSchema, SET_TARGETING_RULE_DESCRIPTION, } from "./tools/setTargetingRule.js";
|
|
22
|
+
import { removeTargetingRule, removeTargetingRuleInputSchema, REMOVE_TARGETING_RULE_DESCRIPTION, } from "./tools/removeTargetingRule.js";
|
|
23
|
+
import { listSubLabels, listSubLabelsInputSchema, LIST_SUB_LABELS_DESCRIPTION, } from "./tools/listSubLabels.js";
|
|
24
|
+
import { setSubLabels, setSubLabelsInputSchema, SET_SUB_LABELS_DESCRIPTION, } from "./tools/setSubLabels.js";
|
|
25
|
+
import { listConversions, listConversionsInputSchema, LIST_CONVERSIONS_DESCRIPTION, } from "./tools/listConversions.js";
|
|
26
|
+
import { whoami, whoamiInputSchema, WHOAMI_DESCRIPTION } from "./tools/whoami.js";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { version: VERSION } = require("../package.json");
|
|
30
|
+
/** Build the MCP server with the affset tools registered against `config`. */
|
|
31
|
+
export function createServer(config) {
|
|
32
|
+
const client = new AffsetClient(config);
|
|
33
|
+
const server = new McpServer({
|
|
34
|
+
name: "affset-mcp",
|
|
35
|
+
version: VERSION,
|
|
36
|
+
});
|
|
37
|
+
// Registers every tool as normal, then immediately removes anything that isn't
|
|
38
|
+
// readOnlyHint:true when AFFSET_READ_ONLY is set — a mutation tool never has a
|
|
39
|
+
// chance to appear in tools/list or be called. See AFFSET_READ_ONLY in the README
|
|
40
|
+
// for why this exists (untrusted third-party data can reach model context via
|
|
41
|
+
// list_conversions/get_stats, so mutation tools are an injection blast-radius
|
|
42
|
+
// control, not just a UI nicety).
|
|
43
|
+
const registerTool = (name, toolConfig, cb) => {
|
|
44
|
+
const tool = server.registerTool(name, toolConfig, cb);
|
|
45
|
+
if (config.readOnly && toolConfig.annotations?.readOnlyHint !== true) {
|
|
46
|
+
tool.remove();
|
|
47
|
+
}
|
|
48
|
+
return tool;
|
|
49
|
+
};
|
|
50
|
+
registerTool("whoami", {
|
|
51
|
+
title: "Show the tenant this MCP is bound to",
|
|
52
|
+
description: WHOAMI_DESCRIPTION,
|
|
53
|
+
inputSchema: whoamiInputSchema,
|
|
54
|
+
annotations: {
|
|
55
|
+
readOnlyHint: true,
|
|
56
|
+
destructiveHint: false,
|
|
57
|
+
idempotentHint: true,
|
|
58
|
+
openWorldHint: true,
|
|
59
|
+
},
|
|
60
|
+
}, () => whoami(client, config));
|
|
61
|
+
registerTool("get_stats", {
|
|
62
|
+
title: "Get affset stats",
|
|
63
|
+
description: GET_STATS_DESCRIPTION,
|
|
64
|
+
inputSchema: getStatsInputSchema,
|
|
65
|
+
annotations: {
|
|
66
|
+
readOnlyHint: true,
|
|
67
|
+
destructiveHint: false,
|
|
68
|
+
idempotentHint: true,
|
|
69
|
+
openWorldHint: true,
|
|
70
|
+
},
|
|
71
|
+
}, (args) => getStats(client, args));
|
|
72
|
+
registerTool("list_campaigns", {
|
|
73
|
+
title: "List campaigns",
|
|
74
|
+
description: LIST_CAMPAIGNS_DESCRIPTION,
|
|
75
|
+
inputSchema: listCampaignsInputSchema,
|
|
76
|
+
annotations: {
|
|
77
|
+
readOnlyHint: true,
|
|
78
|
+
destructiveHint: false,
|
|
79
|
+
idempotentHint: true,
|
|
80
|
+
openWorldHint: true,
|
|
81
|
+
},
|
|
82
|
+
}, (args) => listCampaigns(client, args));
|
|
83
|
+
registerTool("list_zones", {
|
|
84
|
+
title: "List zones",
|
|
85
|
+
description: LIST_ZONES_DESCRIPTION,
|
|
86
|
+
inputSchema: listZonesInputSchema,
|
|
87
|
+
annotations: {
|
|
88
|
+
readOnlyHint: true,
|
|
89
|
+
destructiveHint: false,
|
|
90
|
+
idempotentHint: true,
|
|
91
|
+
openWorldHint: true,
|
|
92
|
+
},
|
|
93
|
+
}, (args) => listZones(client, args));
|
|
94
|
+
registerTool("list_team", {
|
|
95
|
+
title: "List team members",
|
|
96
|
+
description: LIST_TEAM_DESCRIPTION,
|
|
97
|
+
inputSchema: listTeamInputSchema,
|
|
98
|
+
annotations: {
|
|
99
|
+
readOnlyHint: true,
|
|
100
|
+
destructiveHint: false,
|
|
101
|
+
idempotentHint: true,
|
|
102
|
+
openWorldHint: true,
|
|
103
|
+
},
|
|
104
|
+
}, (args) => listTeam(client, args));
|
|
105
|
+
registerTool("get_zone_url", {
|
|
106
|
+
title: "Get the zone URL for a traffic source",
|
|
107
|
+
description: GET_ZONE_URL_DESCRIPTION,
|
|
108
|
+
inputSchema: getZoneUrlInputSchema,
|
|
109
|
+
annotations: {
|
|
110
|
+
readOnlyHint: true,
|
|
111
|
+
destructiveHint: false,
|
|
112
|
+
idempotentHint: true,
|
|
113
|
+
openWorldHint: true,
|
|
114
|
+
},
|
|
115
|
+
}, (args) => getZoneUrl(client, config, args));
|
|
116
|
+
registerTool("get_tracking_link", {
|
|
117
|
+
title: "Get a campaign's tracking link",
|
|
118
|
+
description: GET_TRACKING_LINK_DESCRIPTION,
|
|
119
|
+
inputSchema: getTrackingLinkInputSchema,
|
|
120
|
+
annotations: {
|
|
121
|
+
readOnlyHint: true,
|
|
122
|
+
destructiveHint: false,
|
|
123
|
+
idempotentHint: true,
|
|
124
|
+
openWorldHint: true,
|
|
125
|
+
},
|
|
126
|
+
}, (args) => getTrackingLink(client, config, args));
|
|
127
|
+
registerTool("create_campaign", {
|
|
128
|
+
title: "Create a campaign",
|
|
129
|
+
description: CREATE_CAMPAIGN_DESCRIPTION,
|
|
130
|
+
inputSchema: createCampaignInputSchema,
|
|
131
|
+
annotations: {
|
|
132
|
+
readOnlyHint: false,
|
|
133
|
+
destructiveHint: false,
|
|
134
|
+
idempotentHint: false,
|
|
135
|
+
openWorldHint: true,
|
|
136
|
+
},
|
|
137
|
+
}, (args) => createCampaign(client, config, args));
|
|
138
|
+
registerTool("update_campaign", {
|
|
139
|
+
title: "Update a campaign",
|
|
140
|
+
description: UPDATE_CAMPAIGN_DESCRIPTION,
|
|
141
|
+
inputSchema: updateCampaignInputSchema,
|
|
142
|
+
annotations: {
|
|
143
|
+
readOnlyHint: false,
|
|
144
|
+
destructiveHint: true,
|
|
145
|
+
idempotentHint: false,
|
|
146
|
+
openWorldHint: true,
|
|
147
|
+
},
|
|
148
|
+
}, (args) => updateCampaign(client, args));
|
|
149
|
+
registerTool("set_campaign_status", {
|
|
150
|
+
title: "Run or pause a campaign",
|
|
151
|
+
description: SET_CAMPAIGN_STATUS_DESCRIPTION,
|
|
152
|
+
inputSchema: setCampaignStatusInputSchema,
|
|
153
|
+
annotations: {
|
|
154
|
+
readOnlyHint: false,
|
|
155
|
+
destructiveHint: true,
|
|
156
|
+
idempotentHint: false,
|
|
157
|
+
openWorldHint: true,
|
|
158
|
+
},
|
|
159
|
+
}, (args) => setCampaignStatus(client, args));
|
|
160
|
+
registerTool("create_zone", {
|
|
161
|
+
title: "Create a zone",
|
|
162
|
+
description: CREATE_ZONE_DESCRIPTION,
|
|
163
|
+
inputSchema: createZoneInputSchema,
|
|
164
|
+
annotations: {
|
|
165
|
+
readOnlyHint: false,
|
|
166
|
+
destructiveHint: false,
|
|
167
|
+
idempotentHint: false,
|
|
168
|
+
openWorldHint: true,
|
|
169
|
+
},
|
|
170
|
+
}, (args) => createZone(client, args));
|
|
171
|
+
registerTool("update_zone", {
|
|
172
|
+
title: "Update a zone",
|
|
173
|
+
description: UPDATE_ZONE_DESCRIPTION,
|
|
174
|
+
inputSchema: updateZoneInputSchema,
|
|
175
|
+
annotations: {
|
|
176
|
+
readOnlyHint: false,
|
|
177
|
+
destructiveHint: true,
|
|
178
|
+
idempotentHint: false,
|
|
179
|
+
openWorldHint: true,
|
|
180
|
+
},
|
|
181
|
+
}, (args) => updateZone(client, args));
|
|
182
|
+
registerTool("cut_zones", {
|
|
183
|
+
title: "Cut underperforming zones",
|
|
184
|
+
description: CUT_ZONES_DESCRIPTION,
|
|
185
|
+
inputSchema: cutZonesInputSchema,
|
|
186
|
+
annotations: {
|
|
187
|
+
readOnlyHint: false,
|
|
188
|
+
destructiveHint: true,
|
|
189
|
+
idempotentHint: false,
|
|
190
|
+
openWorldHint: true,
|
|
191
|
+
},
|
|
192
|
+
}, (args) => cutZones(client, args));
|
|
193
|
+
registerTool("list_payout_rules", {
|
|
194
|
+
title: "List payout rules",
|
|
195
|
+
description: LIST_PAYOUT_RULES_DESCRIPTION,
|
|
196
|
+
inputSchema: listPayoutRulesInputSchema,
|
|
197
|
+
annotations: {
|
|
198
|
+
readOnlyHint: true,
|
|
199
|
+
destructiveHint: false,
|
|
200
|
+
idempotentHint: true,
|
|
201
|
+
openWorldHint: true,
|
|
202
|
+
},
|
|
203
|
+
}, (args) => listPayoutRules(client, args));
|
|
204
|
+
registerTool("set_payout_rule", {
|
|
205
|
+
title: "Set a payout rule",
|
|
206
|
+
description: SET_PAYOUT_RULE_DESCRIPTION,
|
|
207
|
+
inputSchema: setPayoutRuleInputSchema,
|
|
208
|
+
annotations: {
|
|
209
|
+
readOnlyHint: false,
|
|
210
|
+
destructiveHint: true,
|
|
211
|
+
idempotentHint: false,
|
|
212
|
+
openWorldHint: true,
|
|
213
|
+
},
|
|
214
|
+
}, (args) => setPayoutRule(client, args));
|
|
215
|
+
registerTool("delete_payout_rule", {
|
|
216
|
+
title: "Delete a payout rule",
|
|
217
|
+
description: DELETE_PAYOUT_RULE_DESCRIPTION,
|
|
218
|
+
inputSchema: deletePayoutRuleInputSchema,
|
|
219
|
+
annotations: {
|
|
220
|
+
readOnlyHint: false,
|
|
221
|
+
destructiveHint: true,
|
|
222
|
+
idempotentHint: false,
|
|
223
|
+
openWorldHint: true,
|
|
224
|
+
},
|
|
225
|
+
}, (args) => deletePayoutRule(client, args));
|
|
226
|
+
registerTool("set_payout_goal", {
|
|
227
|
+
title: "Set payout goal type",
|
|
228
|
+
description: SET_PAYOUT_GOAL_DESCRIPTION,
|
|
229
|
+
inputSchema: setPayoutGoalInputSchema,
|
|
230
|
+
annotations: {
|
|
231
|
+
readOnlyHint: false,
|
|
232
|
+
destructiveHint: true,
|
|
233
|
+
idempotentHint: false,
|
|
234
|
+
openWorldHint: true,
|
|
235
|
+
},
|
|
236
|
+
}, (args) => setPayoutGoal(client, args));
|
|
237
|
+
registerTool("list_targeting_types", {
|
|
238
|
+
title: "List targeting rule types",
|
|
239
|
+
description: LIST_TARGETING_TYPES_DESCRIPTION,
|
|
240
|
+
inputSchema: listTargetingTypesInputSchema,
|
|
241
|
+
annotations: {
|
|
242
|
+
readOnlyHint: true,
|
|
243
|
+
destructiveHint: false,
|
|
244
|
+
idempotentHint: true,
|
|
245
|
+
openWorldHint: true,
|
|
246
|
+
},
|
|
247
|
+
}, () => listTargetingTypes(client));
|
|
248
|
+
registerTool("list_targeting_rules", {
|
|
249
|
+
title: "List campaign targeting rules",
|
|
250
|
+
description: LIST_TARGETING_RULES_DESCRIPTION,
|
|
251
|
+
inputSchema: listTargetingRulesInputSchema,
|
|
252
|
+
annotations: {
|
|
253
|
+
readOnlyHint: true,
|
|
254
|
+
destructiveHint: false,
|
|
255
|
+
idempotentHint: true,
|
|
256
|
+
openWorldHint: true,
|
|
257
|
+
},
|
|
258
|
+
}, (args) => listTargetingRules(client, args));
|
|
259
|
+
registerTool("set_targeting_rule", {
|
|
260
|
+
title: "Set a targeting rule",
|
|
261
|
+
description: SET_TARGETING_RULE_DESCRIPTION,
|
|
262
|
+
inputSchema: setTargetingRuleInputSchema,
|
|
263
|
+
annotations: {
|
|
264
|
+
readOnlyHint: false,
|
|
265
|
+
destructiveHint: true,
|
|
266
|
+
idempotentHint: false,
|
|
267
|
+
openWorldHint: true,
|
|
268
|
+
},
|
|
269
|
+
}, (args) => setTargetingRule(client, args));
|
|
270
|
+
registerTool("remove_targeting_rule", {
|
|
271
|
+
title: "Remove a targeting rule",
|
|
272
|
+
description: REMOVE_TARGETING_RULE_DESCRIPTION,
|
|
273
|
+
inputSchema: removeTargetingRuleInputSchema,
|
|
274
|
+
annotations: {
|
|
275
|
+
readOnlyHint: false,
|
|
276
|
+
destructiveHint: true,
|
|
277
|
+
idempotentHint: false,
|
|
278
|
+
openWorldHint: true,
|
|
279
|
+
},
|
|
280
|
+
}, (args) => removeTargetingRule(client, args));
|
|
281
|
+
registerTool("list_sub_labels", {
|
|
282
|
+
title: "List sub labels",
|
|
283
|
+
description: LIST_SUB_LABELS_DESCRIPTION,
|
|
284
|
+
inputSchema: listSubLabelsInputSchema,
|
|
285
|
+
annotations: {
|
|
286
|
+
readOnlyHint: true,
|
|
287
|
+
destructiveHint: false,
|
|
288
|
+
idempotentHint: true,
|
|
289
|
+
openWorldHint: true,
|
|
290
|
+
},
|
|
291
|
+
}, () => listSubLabels(client));
|
|
292
|
+
registerTool("set_sub_labels", {
|
|
293
|
+
title: "Set sub labels",
|
|
294
|
+
description: SET_SUB_LABELS_DESCRIPTION,
|
|
295
|
+
inputSchema: setSubLabelsInputSchema,
|
|
296
|
+
annotations: {
|
|
297
|
+
readOnlyHint: false,
|
|
298
|
+
destructiveHint: true,
|
|
299
|
+
idempotentHint: false,
|
|
300
|
+
openWorldHint: true,
|
|
301
|
+
},
|
|
302
|
+
}, (args) => setSubLabels(client, args));
|
|
303
|
+
registerTool("list_conversions", {
|
|
304
|
+
title: "List conversions",
|
|
305
|
+
description: LIST_CONVERSIONS_DESCRIPTION,
|
|
306
|
+
inputSchema: listConversionsInputSchema,
|
|
307
|
+
annotations: {
|
|
308
|
+
readOnlyHint: true,
|
|
309
|
+
destructiveHint: false,
|
|
310
|
+
idempotentHint: true,
|
|
311
|
+
openWorldHint: true,
|
|
312
|
+
},
|
|
313
|
+
}, (args) => listConversions(client, args));
|
|
314
|
+
return server;
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=server.js.map
|