@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mdCell } from "../lib/format.js";
|
|
3
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
4
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
5
|
+
import { SUB_KEYS } from "../types.js";
|
|
6
|
+
/** Matches lite-adserver SUB_LABEL_MAX_LENGTH. */
|
|
7
|
+
const SUB_LABEL_MAX = 40;
|
|
8
|
+
const clearableLabel = z.union([z.string().max(SUB_LABEL_MAX), z.null()]);
|
|
9
|
+
export const SET_SUB_LABELS_DESCRIPTION = "Set or clear tenant display names for sub1–sub5. Partial update — only provided " +
|
|
10
|
+
'keys change; pass null or "" to clear a label. Max 40 chars each. DRY-RUN by ' +
|
|
11
|
+
"default; pass confirm=true to apply. Affects stats column titles and link helpers.";
|
|
12
|
+
export const setSubLabelsInputSchema = {
|
|
13
|
+
sub1: clearableLabel.optional().describe('Display name for sub1, or null/"" to clear.'),
|
|
14
|
+
sub2: clearableLabel.optional().describe('Display name for sub2, or null/"" to clear.'),
|
|
15
|
+
sub3: clearableLabel.optional().describe('Display name for sub3, or null/"" to clear.'),
|
|
16
|
+
sub4: clearableLabel.optional().describe('Display name for sub4, or null/"" to clear.'),
|
|
17
|
+
sub5: clearableLabel.optional().describe('Display name for sub5, or null/"" to clear.'),
|
|
18
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
19
|
+
};
|
|
20
|
+
export async function setSubLabels(client, args) {
|
|
21
|
+
try {
|
|
22
|
+
const patch = buildPatch(args);
|
|
23
|
+
if ("error" in patch)
|
|
24
|
+
return textError(patch.error);
|
|
25
|
+
if (Object.keys(patch.updates).length === 0) {
|
|
26
|
+
return textError('Provide at least one of sub1..sub5 to set or clear (null/"" clears).');
|
|
27
|
+
}
|
|
28
|
+
const settings = await client.get("/api/tenant");
|
|
29
|
+
const current = settings.sub_labels ?? {};
|
|
30
|
+
const changes = diffLabels(current, patch.updates);
|
|
31
|
+
if (changes.length === 0) {
|
|
32
|
+
return textResult("Nothing to change — provided sub labels already match current values.");
|
|
33
|
+
}
|
|
34
|
+
if (!args.confirm) {
|
|
35
|
+
return textResult([
|
|
36
|
+
"**Dry run** — would update tenant sub labels.",
|
|
37
|
+
"",
|
|
38
|
+
renderDiff(changes),
|
|
39
|
+
"",
|
|
40
|
+
"Call again with `confirm: true` to apply.",
|
|
41
|
+
].join("\n"));
|
|
42
|
+
}
|
|
43
|
+
const updated = await client.put("/api/tenant", {
|
|
44
|
+
sub_labels: patch.updates,
|
|
45
|
+
});
|
|
46
|
+
const next = updated.sub_labels ?? {};
|
|
47
|
+
return textResult(["✅ Sub labels updated.", "", renderDiff(changes), "", renderFinal(next)].join("\n"));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return errorResult(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function buildPatch(args) {
|
|
54
|
+
const updates = {};
|
|
55
|
+
for (const key of SUB_KEYS) {
|
|
56
|
+
if (!(key in args) || args[key] === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const raw = args[key];
|
|
59
|
+
if (raw === null) {
|
|
60
|
+
updates[key] = null;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const trimmed = raw.trim();
|
|
64
|
+
if (trimmed.length === 0) {
|
|
65
|
+
updates[key] = null;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (trimmed.length > SUB_LABEL_MAX) {
|
|
69
|
+
return {
|
|
70
|
+
error: `${key} must be at most ${SUB_LABEL_MAX} characters (got ${trimmed.length}).`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
updates[key] = trimmed;
|
|
74
|
+
}
|
|
75
|
+
return { updates };
|
|
76
|
+
}
|
|
77
|
+
function diffLabels(current, updates) {
|
|
78
|
+
const changes = [];
|
|
79
|
+
for (const key of SUB_KEYS) {
|
|
80
|
+
if (!(key in updates))
|
|
81
|
+
continue;
|
|
82
|
+
const from = current[key]?.trim() || null;
|
|
83
|
+
const to = updates[key] ?? null;
|
|
84
|
+
if (from === to)
|
|
85
|
+
continue;
|
|
86
|
+
changes.push({
|
|
87
|
+
field: key,
|
|
88
|
+
from: from ? mdCell(from) : displayValue(null),
|
|
89
|
+
to: to ? mdCell(to) : displayValue(null),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return changes;
|
|
93
|
+
}
|
|
94
|
+
function renderFinal(labels) {
|
|
95
|
+
const lines = ["| Key | Label |", "|---|---|"];
|
|
96
|
+
for (const key of SUB_KEYS) {
|
|
97
|
+
const label = labels[key]?.trim();
|
|
98
|
+
lines.push(`| \`${key}\` | ${label ? mdCell(label) : "_—_"} |`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=setSubLabels.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { mdCell } from "../lib/format.js";
|
|
4
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
5
|
+
import { fetchTargetingRules, fetchTargetingTypes, normalizeRuleValue, resolveTargetingType, syncTargetingRules, toSyncPayload, UNENFORCED_TYPES, } from "../lib/targeting.js";
|
|
6
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
7
|
+
export const SET_TARGETING_RULE_DESCRIPTION = "Upsert one targeting rule on a campaign (safe merge — other rules are kept). " +
|
|
8
|
+
"Identify the type by id or name (e.g. geo, zone_id, device_type, os, browser, " +
|
|
9
|
+
"unique_users). If a rule with the same type + method already exists it is updated; " +
|
|
10
|
+
"otherwise a new one is added. `rule` is comma-separated values (geo: BR,MX; " +
|
|
11
|
+
"unique_users: visits/hours). Values are normalised to what /serve matches — an " +
|
|
12
|
+
"unmatched whitelist stops delivery. DRY-RUN by default; pass confirm=true to apply.";
|
|
13
|
+
export const setTargetingRuleInputSchema = {
|
|
14
|
+
campaign_id: z.union([z.string().min(1), z.number().int()]).describe("Campaign to edit."),
|
|
15
|
+
type: z
|
|
16
|
+
.union([z.string().min(1), z.number().int()])
|
|
17
|
+
.describe('Targeting type id (number) or name (e.g. "geo", "zone_id", "device_type"). ' +
|
|
18
|
+
"Call list_targeting_types for the catalog."),
|
|
19
|
+
method: z.enum(["whitelist", "blacklist"]).describe("whitelist or blacklist."),
|
|
20
|
+
rule: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe('Rule value(s). Usually comma-separated (e.g. "BR,MX", zone UUIDs, "desktop"). ' +
|
|
24
|
+
'unique_users uses "visits/hours".'),
|
|
25
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
26
|
+
};
|
|
27
|
+
export async function setTargetingRule(client, args) {
|
|
28
|
+
try {
|
|
29
|
+
const campaignId = String(args.campaign_id).trim();
|
|
30
|
+
if (!campaignId)
|
|
31
|
+
return textError("campaign_id is required.");
|
|
32
|
+
const [types, current] = await Promise.all([
|
|
33
|
+
fetchTargetingTypes(client),
|
|
34
|
+
fetchTargetingRules(client, campaignId),
|
|
35
|
+
]);
|
|
36
|
+
const resolved = resolveTargetingType(types, args.type);
|
|
37
|
+
if ("error" in resolved)
|
|
38
|
+
return textError(resolved.error);
|
|
39
|
+
const type = resolved.type;
|
|
40
|
+
const unenforced = UNENFORCED_TYPES[type.name.toLowerCase()];
|
|
41
|
+
if (unenforced) {
|
|
42
|
+
return textError(`\`${type.name}\` rules are stored by the API but ${unenforced}. ` +
|
|
43
|
+
"Setting one here would read as working targeting while the campaign keeps buying, " +
|
|
44
|
+
"so this tool does not write it.");
|
|
45
|
+
}
|
|
46
|
+
const normalized = normalizeRuleValue(type.name, args.rule);
|
|
47
|
+
if ("error" in normalized)
|
|
48
|
+
return textError(normalized.error);
|
|
49
|
+
const ruleValue = normalized.value;
|
|
50
|
+
const existing = current.find((r) => r.targeting_rule_type_id === type.id && r.targeting_method === args.method);
|
|
51
|
+
if (existing && existing.rule === ruleValue) {
|
|
52
|
+
return textResult(`Nothing to change — campaign \`${campaignId}\` already has ` +
|
|
53
|
+
`${mdCell(type.name)} ${args.method} = \`${mdCell(ruleValue)}\` ` +
|
|
54
|
+
`(rule id \`${existing.id}\`).`);
|
|
55
|
+
}
|
|
56
|
+
const diff = renderDiff([
|
|
57
|
+
{
|
|
58
|
+
field: `${type.name} (${args.method})`,
|
|
59
|
+
from: existing ? existing.rule : displayValue(null),
|
|
60
|
+
to: ruleValue,
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
const notes = [
|
|
64
|
+
...normalized.notes,
|
|
65
|
+
...opposingRuleNote(current, type.id, type.name, args.method),
|
|
66
|
+
];
|
|
67
|
+
if (!args.confirm) {
|
|
68
|
+
const verb = existing ? "update" : "add";
|
|
69
|
+
return textResult([
|
|
70
|
+
`**Dry run** — would ${verb} targeting on campaign \`${campaignId}\`.`,
|
|
71
|
+
"",
|
|
72
|
+
diff,
|
|
73
|
+
...(notes.length ? ["", ...notes] : []),
|
|
74
|
+
"",
|
|
75
|
+
`Type: ${mdCell(type.name)} (id ${type.id}). Other rules stay untouched.`,
|
|
76
|
+
"",
|
|
77
|
+
"Call again with `confirm: true` to apply.",
|
|
78
|
+
].join("\n"));
|
|
79
|
+
}
|
|
80
|
+
const applied = await syncTargetingRules(client, campaignId, upsertRule(current, type.id, args.method, ruleValue));
|
|
81
|
+
const written = applied.find((r) => r.targeting_rule_type_id === type.id && r.targeting_method === args.method);
|
|
82
|
+
return textResult([
|
|
83
|
+
`✅ Targeting ${existing ? "updated" : "added"} on campaign \`${campaignId}\`` +
|
|
84
|
+
(written?.id != null ? ` (rule id \`${written.id}\`)` : "") +
|
|
85
|
+
".",
|
|
86
|
+
"",
|
|
87
|
+
diff,
|
|
88
|
+
...(notes.length ? ["", ...notes] : []),
|
|
89
|
+
].join("\n"));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
93
|
+
return textError(`Campaign \`${String(args.campaign_id).trim()}\` not found in this namespace.`);
|
|
94
|
+
}
|
|
95
|
+
return errorResult(err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A whitelist and a blacklist of the same type both apply, and the blacklist
|
|
100
|
+
* wins on any overlap — easy to set by accident when the intent was to replace
|
|
101
|
+
* the other one.
|
|
102
|
+
*/
|
|
103
|
+
function opposingRuleNote(rules, typeId, typeName, method) {
|
|
104
|
+
const opposite = method === "whitelist" ? "blacklist" : "whitelist";
|
|
105
|
+
const other = rules.find((r) => r.targeting_rule_type_id === typeId && r.targeting_method === opposite);
|
|
106
|
+
if (!other)
|
|
107
|
+
return [];
|
|
108
|
+
return [
|
|
109
|
+
`⚠️ Campaign also has a ${typeName} **${opposite}** (\`${mdCell(other.rule)}\`) — ` +
|
|
110
|
+
`both apply. Use \`remove_targeting_rule\` if it should be replaced instead.`,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
/** Echo every existing rule (with id) and upsert the matching type+method slot. */
|
|
114
|
+
function upsertRule(rules, typeId, method, rule) {
|
|
115
|
+
const next = toSyncPayload(rules);
|
|
116
|
+
const target = next.find((r) => r.targeting_rule_type_id === typeId && r.targeting_method === method);
|
|
117
|
+
if (target) {
|
|
118
|
+
target.rule = rule;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
next.push({ targeting_rule_type_id: typeId, targeting_method: method, rule });
|
|
122
|
+
}
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=setTargetingRule.js.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
4
|
+
import { money } from "../lib/format.js";
|
|
5
|
+
import { parseCampaignDateBound } from "../lib/time.js";
|
|
6
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
7
|
+
import { httpUrlError } from "../lib/urls.js";
|
|
8
|
+
import { CAMPAIGN_STATUSES, PACING_VALUES, PAYMENT_MODELS, } from "../types.js";
|
|
9
|
+
export const UPDATE_CAMPAIGN_DESCRIPTION = "Update a campaign (partial): name, offer URL (redirect_url), status, rate, " +
|
|
10
|
+
"payment_model, start/end dates, daily/total budget, pacing. DRY-RUN by default; " +
|
|
11
|
+
"pass confirm=true to apply. For run/pause prefer set_campaign_status. Activating " +
|
|
12
|
+
"a paused campaign can hit the plan's active-campaign limit (402). Targeting rules " +
|
|
13
|
+
"and payout rules are separate tools.";
|
|
14
|
+
export const updateCampaignInputSchema = {
|
|
15
|
+
campaign_id: z.union([z.string().min(1), z.number().int()]).describe("Campaign id to update."),
|
|
16
|
+
name: z.string().min(1).max(200).optional().describe("New campaign name."),
|
|
17
|
+
redirect_url: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Offer / lander URL (redirect target). http(s) required."),
|
|
22
|
+
status: z
|
|
23
|
+
.enum(CAMPAIGN_STATUSES)
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("active | paused | archived. active enters /serve rotation (plan limit)."),
|
|
26
|
+
payment_model: z.enum(PAYMENT_MODELS).optional().describe("cpa or cpm."),
|
|
27
|
+
rate: z
|
|
28
|
+
.number()
|
|
29
|
+
.min(0)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Internal advertiser rate (usually 0 for media-buying)."),
|
|
32
|
+
start_date: z
|
|
33
|
+
.union([z.string(), z.number(), z.null()])
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Start bound: YYYY-MM-DD (tenant-local start of day), ISO timestamp with Z/UTC offset, epoch ms, or null to clear."),
|
|
36
|
+
end_date: z
|
|
37
|
+
.union([z.string(), z.number(), z.null()])
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("End bound: YYYY-MM-DD (tenant-local end of day), ISO timestamp with Z/UTC offset, epoch ms, or null to clear."),
|
|
40
|
+
daily_budget: z
|
|
41
|
+
.union([z.number().min(0), z.null()])
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Daily budget cap in USD, or null to clear."),
|
|
44
|
+
total_budget: z
|
|
45
|
+
.union([z.number().min(0), z.null()])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Total budget cap in USD, or null to clear."),
|
|
48
|
+
pacing: z.enum(PACING_VALUES).optional().describe("Budget pacing: asap or even."),
|
|
49
|
+
confirm: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.default(false)
|
|
52
|
+
.describe("false = dry-run preview (default). true = apply the update."),
|
|
53
|
+
};
|
|
54
|
+
export async function updateCampaign(client, args) {
|
|
55
|
+
try {
|
|
56
|
+
const campaignId = String(args.campaign_id).trim();
|
|
57
|
+
if (!campaignId)
|
|
58
|
+
return textError("campaign_id is required.");
|
|
59
|
+
const needsTenantTimezone = [args.start_date, args.end_date].some((value) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim()));
|
|
60
|
+
const timeZone = needsTenantTimezone ? await client.getRequiredTenantTimezone() : "UTC";
|
|
61
|
+
const patch = buildPatch(args, timeZone);
|
|
62
|
+
if ("error" in patch)
|
|
63
|
+
return textError(patch.error);
|
|
64
|
+
if (Object.keys(patch.body).length === 0) {
|
|
65
|
+
return textError("Provide at least one field to update: name, redirect_url, status, payment_model, " +
|
|
66
|
+
"rate, start_date, end_date, daily_budget, total_budget, pacing.");
|
|
67
|
+
}
|
|
68
|
+
let existing;
|
|
69
|
+
try {
|
|
70
|
+
existing = await client.get(`/api/campaigns/${encodeURIComponent(campaignId)}`);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
74
|
+
return textError(`Campaign \`${campaignId}\` not found in this namespace.`);
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
const dateError = validateDateOrder(existing, patch.body);
|
|
79
|
+
if (dateError)
|
|
80
|
+
return textError(dateError);
|
|
81
|
+
const changes = diffCampaign(existing, patch.body);
|
|
82
|
+
if (changes.length === 0) {
|
|
83
|
+
return textResult(`Nothing to change on campaign \`${existing.id}\` (${existing.name}) — ` +
|
|
84
|
+
"provided fields already match current values.");
|
|
85
|
+
}
|
|
86
|
+
const warnings = buildWarnings(existing, patch.body);
|
|
87
|
+
if (!args.confirm) {
|
|
88
|
+
return textResult([
|
|
89
|
+
`**Dry run** — would update campaign \`${existing.id}\` (${existing.name}).`,
|
|
90
|
+
"",
|
|
91
|
+
renderDiff(changes),
|
|
92
|
+
...(warnings.length ? ["", ...warnings] : []),
|
|
93
|
+
"",
|
|
94
|
+
"Call again with `confirm: true` to apply.",
|
|
95
|
+
].join("\n"));
|
|
96
|
+
}
|
|
97
|
+
const updated = await client.put(`/api/campaigns/${encodeURIComponent(campaignId)}`, patch.body);
|
|
98
|
+
return textResult([
|
|
99
|
+
`✅ Campaign \`${updated.id}\` updated.`,
|
|
100
|
+
"",
|
|
101
|
+
renderDiff(changes),
|
|
102
|
+
...(warnings.length ? ["", ...warnings] : []),
|
|
103
|
+
].join("\n"));
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return errorResult(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function buildPatch(args, timeZone) {
|
|
110
|
+
const body = {};
|
|
111
|
+
if (args.name !== undefined) {
|
|
112
|
+
const name = args.name.trim();
|
|
113
|
+
if (!name)
|
|
114
|
+
return { error: "name must be a non-empty string." };
|
|
115
|
+
body.name = name;
|
|
116
|
+
}
|
|
117
|
+
if (args.redirect_url !== undefined) {
|
|
118
|
+
const err = httpUrlError(args.redirect_url, "redirect_url");
|
|
119
|
+
if (err)
|
|
120
|
+
return { error: err };
|
|
121
|
+
body.redirect_url = args.redirect_url;
|
|
122
|
+
}
|
|
123
|
+
if (args.status !== undefined)
|
|
124
|
+
body.status = args.status;
|
|
125
|
+
if (args.payment_model !== undefined)
|
|
126
|
+
body.payment_model = args.payment_model;
|
|
127
|
+
if (args.rate !== undefined)
|
|
128
|
+
body.rate = args.rate;
|
|
129
|
+
if (args.pacing !== undefined)
|
|
130
|
+
body.pacing = args.pacing;
|
|
131
|
+
if (args.daily_budget !== undefined)
|
|
132
|
+
body.daily_budget = args.daily_budget;
|
|
133
|
+
if (args.total_budget !== undefined)
|
|
134
|
+
body.total_budget = args.total_budget;
|
|
135
|
+
if (args.start_date !== undefined) {
|
|
136
|
+
const parsed = parseDateBound(args.start_date, "start_date", timeZone);
|
|
137
|
+
if ("error" in parsed)
|
|
138
|
+
return parsed;
|
|
139
|
+
body.start_date = parsed.value;
|
|
140
|
+
}
|
|
141
|
+
if (args.end_date !== undefined) {
|
|
142
|
+
const parsed = parseDateBound(args.end_date, "end_date", timeZone);
|
|
143
|
+
if ("error" in parsed)
|
|
144
|
+
return parsed;
|
|
145
|
+
body.end_date = parsed.value;
|
|
146
|
+
}
|
|
147
|
+
return { body };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* YYYY-MM-DD → tenant-local start-of-day for start_date, end-of-day for end_date
|
|
151
|
+
* (so "end 2026-07-25" keeps the campaign live through that calendar day).
|
|
152
|
+
*/
|
|
153
|
+
function parseDateBound(value, label, timeZone) {
|
|
154
|
+
try {
|
|
155
|
+
return {
|
|
156
|
+
value: parseCampaignDateBound(value, label === "start_date" ? "start" : "end", timeZone),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return { error: `Invalid ${label}: ${message}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Reject inverted ranges against the patch and/or the existing campaign. */
|
|
165
|
+
function validateDateOrder(existing, body) {
|
|
166
|
+
const start = body.start_date !== undefined ? body.start_date : (existing.start_date ?? null);
|
|
167
|
+
const end = body.end_date !== undefined ? body.end_date : (existing.end_date ?? null);
|
|
168
|
+
if (typeof start === "number" && typeof end === "number" && end <= start) {
|
|
169
|
+
return "end_date must be after start_date.";
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
function diffCampaign(existing, body) {
|
|
174
|
+
const changes = [];
|
|
175
|
+
for (const [field, to] of Object.entries(body)) {
|
|
176
|
+
const from = existing[field];
|
|
177
|
+
if (normalizeCompare(from) === normalizeCompare(to))
|
|
178
|
+
continue;
|
|
179
|
+
changes.push({
|
|
180
|
+
field,
|
|
181
|
+
from: formatField(field, from),
|
|
182
|
+
to: formatField(field, to),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return changes;
|
|
186
|
+
}
|
|
187
|
+
function normalizeCompare(value) {
|
|
188
|
+
if (value === undefined || value === null)
|
|
189
|
+
return "";
|
|
190
|
+
return String(value);
|
|
191
|
+
}
|
|
192
|
+
function formatField(field, value) {
|
|
193
|
+
if (value === undefined || value === null)
|
|
194
|
+
return displayValue(value ?? null);
|
|
195
|
+
if ((field === "daily_budget" || field === "total_budget" || field === "rate") &&
|
|
196
|
+
typeof value === "number") {
|
|
197
|
+
return field === "rate" ? String(value) : money(value);
|
|
198
|
+
}
|
|
199
|
+
if ((field === "start_date" || field === "end_date") && typeof value === "number") {
|
|
200
|
+
return new Date(value).toISOString();
|
|
201
|
+
}
|
|
202
|
+
return displayValue(value);
|
|
203
|
+
}
|
|
204
|
+
function buildWarnings(existing, body) {
|
|
205
|
+
const warnings = [];
|
|
206
|
+
if (body.status === "active" && existing.status !== "active") {
|
|
207
|
+
warnings.push("⚠️ Activating enables direct tracking links, enters `/serve` rotation, and counts against the plan's " +
|
|
208
|
+
"**active campaigns** limit (API returns 402 if exceeded).");
|
|
209
|
+
}
|
|
210
|
+
if (body.status === "archived" && existing.status !== "archived") {
|
|
211
|
+
warnings.push("⚠️ Archiving disables direct tracking links and removes the campaign from /serve.");
|
|
212
|
+
}
|
|
213
|
+
if (body.redirect_url !== undefined && body.redirect_url !== existing.redirect_url) {
|
|
214
|
+
warnings.push("⚠️ Changing redirect_url changes where clicks land immediately.");
|
|
215
|
+
}
|
|
216
|
+
return warnings;
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=updateCampaign.js.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { displayValue, renderDiff } from "../lib/patch.js";
|
|
4
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
5
|
+
import { httpUrlError } from "../lib/urls.js";
|
|
6
|
+
import { ZONE_STATUSES } from "../types.js";
|
|
7
|
+
/** Zod: optional http(s) URL, or null to clear. */
|
|
8
|
+
const clearableUrl = z.union([z.string().min(1), z.null()]);
|
|
9
|
+
export const UPDATE_ZONE_DESCRIPTION = "Update a traffic-source zone (name, status, postback_url, site_url, traffic_back_url). " +
|
|
10
|
+
"Partial update — only provided fields change. Pass null for a URL field to clear it. " +
|
|
11
|
+
"DRY-RUN by default; pass confirm=true to apply.";
|
|
12
|
+
export const updateZoneInputSchema = {
|
|
13
|
+
zone_id: z.string().min(1).describe("Zone id to update."),
|
|
14
|
+
name: z.string().min(1).max(200).optional().describe("New display name."),
|
|
15
|
+
status: z.enum(ZONE_STATUSES).optional().describe("active or inactive."),
|
|
16
|
+
postback_url: clearableUrl
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("S2S postback URL, or null to clear. Prefer including {source_click_id}."),
|
|
19
|
+
site_url: clearableUrl.optional().describe("Site URL, or null to clear."),
|
|
20
|
+
traffic_back_url: clearableUrl.optional().describe("Traffic-back URL, or null to clear."),
|
|
21
|
+
confirm: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.default(false)
|
|
24
|
+
.describe("false = dry-run preview (default). true = apply the update."),
|
|
25
|
+
};
|
|
26
|
+
export async function updateZone(client, args) {
|
|
27
|
+
try {
|
|
28
|
+
const patch = buildPatch(args);
|
|
29
|
+
if ("error" in patch)
|
|
30
|
+
return textError(patch.error);
|
|
31
|
+
if (Object.keys(patch.body).length === 0) {
|
|
32
|
+
return textError("Provide at least one field to update: name, status, postback_url, site_url, traffic_back_url.");
|
|
33
|
+
}
|
|
34
|
+
let existing;
|
|
35
|
+
try {
|
|
36
|
+
existing = await client.get(`/api/zones/${encodeURIComponent(args.zone_id)}`);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
40
|
+
return textError(`Zone \`${args.zone_id}\` not found in this namespace.`);
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
const changes = diffZone(existing, patch.body);
|
|
45
|
+
if (changes.length === 0) {
|
|
46
|
+
return textResult(`Nothing to change on zone \`${existing.id}\` (${existing.name}) — ` +
|
|
47
|
+
"provided fields already match current values.");
|
|
48
|
+
}
|
|
49
|
+
const warnings = [];
|
|
50
|
+
if (patch.body.status === "inactive" && existing.status !== "inactive") {
|
|
51
|
+
warnings.push("⚠️ Setting status to **inactive** makes both /serve and direct tracking links " +
|
|
52
|
+
"for this zone return 404 until it is reactivated.");
|
|
53
|
+
}
|
|
54
|
+
if ("postback_url" in patch.body &&
|
|
55
|
+
(patch.body.postback_url === null || patch.body.postback_url === "")) {
|
|
56
|
+
warnings.push("⚠️ Clearing postback_url means the traffic source will stop receiving conversion postbacks.");
|
|
57
|
+
}
|
|
58
|
+
else if (typeof patch.body.postback_url === "string" &&
|
|
59
|
+
!patch.body.postback_url.includes("{source_click_id}")) {
|
|
60
|
+
warnings.push("⚠️ postback_url has no `{source_click_id}` — the source may not attribute conversions.");
|
|
61
|
+
}
|
|
62
|
+
if (!args.confirm) {
|
|
63
|
+
return textResult([
|
|
64
|
+
`**Dry run** — would update zone \`${existing.id}\` (${existing.name}).`,
|
|
65
|
+
"",
|
|
66
|
+
renderDiff(changes),
|
|
67
|
+
...(warnings.length ? ["", ...warnings] : []),
|
|
68
|
+
"",
|
|
69
|
+
"Call again with `confirm: true` to apply.",
|
|
70
|
+
].join("\n"));
|
|
71
|
+
}
|
|
72
|
+
const updated = await client.put(`/api/zones/${encodeURIComponent(args.zone_id)}`, patch.body);
|
|
73
|
+
return textResult([
|
|
74
|
+
`✅ Zone \`${updated.id}\` updated.`,
|
|
75
|
+
"",
|
|
76
|
+
renderDiff(changes),
|
|
77
|
+
...(warnings.length ? ["", ...warnings] : []),
|
|
78
|
+
].join("\n"));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return errorResult(err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function buildPatch(args) {
|
|
85
|
+
const body = {};
|
|
86
|
+
if (args.name !== undefined) {
|
|
87
|
+
const name = args.name.trim();
|
|
88
|
+
if (!name)
|
|
89
|
+
return { error: "name must be a non-empty string." };
|
|
90
|
+
body.name = name;
|
|
91
|
+
}
|
|
92
|
+
if (args.status !== undefined)
|
|
93
|
+
body.status = args.status;
|
|
94
|
+
for (const key of ["postback_url", "site_url", "traffic_back_url"]) {
|
|
95
|
+
if (args[key] === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
if (args[key] === null) {
|
|
98
|
+
body[key] = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const err = httpUrlError(args[key], key);
|
|
102
|
+
if (err)
|
|
103
|
+
return { error: err };
|
|
104
|
+
body[key] = args[key];
|
|
105
|
+
}
|
|
106
|
+
return { body };
|
|
107
|
+
}
|
|
108
|
+
function diffZone(existing, body) {
|
|
109
|
+
const changes = [];
|
|
110
|
+
for (const [field, to] of Object.entries(body)) {
|
|
111
|
+
const from = existing[field];
|
|
112
|
+
if (String(from ?? "") === String(to ?? ""))
|
|
113
|
+
continue;
|
|
114
|
+
changes.push({ field, from: displayValue(from ?? null), to: displayValue(to) });
|
|
115
|
+
}
|
|
116
|
+
return changes;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=updateZone.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mdCell } from "../lib/format.js";
|
|
2
|
+
import { textResult } from "../lib/toolResult.js";
|
|
3
|
+
export const WHOAMI_DESCRIPTION = "Return the tenant this MCP server is bound to: namespace, API base URL, and the " +
|
|
4
|
+
"derived dashboard URL (https://{namespace}.affset.com) — everything you need to " +
|
|
5
|
+
"hand the operator a working deep link, or to pick the right host for a URL you " +
|
|
6
|
+
"were about to guess. Also reports the tenant's company name, timezone and custom " +
|
|
7
|
+
"API domain when the /api/tenant read succeeds. Read-only, no side effects. " +
|
|
8
|
+
"Call this once at the start of a session instead of guessing the namespace from " +
|
|
9
|
+
"the owner's email — one MCP instance = exactly one tenant, and that binding is " +
|
|
10
|
+
"already fixed at startup.";
|
|
11
|
+
export const whoamiInputSchema = {};
|
|
12
|
+
export async function whoami(client, config) {
|
|
13
|
+
const dashboardUrl = `https://${config.namespace}.affset.com`;
|
|
14
|
+
// Tenant fetch is best-effort: config values are enough on their own.
|
|
15
|
+
let tenant = null;
|
|
16
|
+
let tenantError = null;
|
|
17
|
+
try {
|
|
18
|
+
tenant = await client.get("/api/tenant");
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
tenantError = err instanceof Error ? err.message : String(err);
|
|
22
|
+
}
|
|
23
|
+
const customApiDomain = tenant?.custom_api_domain?.trim() || "";
|
|
24
|
+
const lines = [
|
|
25
|
+
`**Tenant** \`${mdCell(config.namespace)}\``,
|
|
26
|
+
"",
|
|
27
|
+
"| Field | Value |",
|
|
28
|
+
"|---|---|",
|
|
29
|
+
`| Namespace | \`${mdCell(config.namespace)}\` |`,
|
|
30
|
+
`| Dashboard | ${dashboardUrl} |`,
|
|
31
|
+
`| API base URL | ${mdCell(config.baseUrl)} |`,
|
|
32
|
+
`| Custom API domain | ${customApiDomain ? mdCell(customApiDomain) : "_(not set — API base is the effective public origin)_"} |`,
|
|
33
|
+
`| Company | ${tenant?.company ? mdCell(tenant.company) : "—"} |`,
|
|
34
|
+
`| Timezone | ${tenant?.timezone ? mdCell(tenant.timezone) : "—"} |`,
|
|
35
|
+
];
|
|
36
|
+
if (tenantError) {
|
|
37
|
+
lines.push("");
|
|
38
|
+
lines.push(`⚠️ Could not read /api/tenant: ${mdCell(tenantError)}. Namespace / dashboard / API base above still valid (from local config).`);
|
|
39
|
+
}
|
|
40
|
+
return textResult(lines.join("\n"));
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=whoami.js.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shapes of the affset tenant API responses this server consumes. */
|
|
2
|
+
/** The five traffic-source breakdown keys. */
|
|
3
|
+
export const SUB_KEYS = ["sub1", "sub2", "sub3", "sub4", "sub5"];
|
|
4
|
+
/** Valid `group_by` dimensions accepted by `GET /api/stats`. */
|
|
5
|
+
export const GROUP_BY_VALUES = [
|
|
6
|
+
"date",
|
|
7
|
+
"campaign_id",
|
|
8
|
+
"zone_id",
|
|
9
|
+
"country",
|
|
10
|
+
"conversion_type",
|
|
11
|
+
"publisher_email",
|
|
12
|
+
...SUB_KEYS,
|
|
13
|
+
];
|
|
14
|
+
export const CAMPAIGN_STATUSES = ["active", "paused", "archived"];
|
|
15
|
+
export const ZONE_STATUSES = ["active", "inactive"];
|
|
16
|
+
export const PAYMENT_MODELS = ["cpa", "cpm"];
|
|
17
|
+
export const PACING_VALUES = ["asap", "even"];
|
|
18
|
+
//# sourceMappingURL=types.js.map
|