@affset/mcp 0.2.0 → 0.3.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.
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { AffsetApiError } from "../client.js";
3
3
  import { mdCell, moneyPrecise } from "../lib/format.js";
4
- import { buildTrackingLink, fetchTenantIntegration } from "../lib/integrationUrls.js";
4
+ import { buildTrackingLink, fetchTenantIntegration, resolveLinkedSource, } from "../lib/integrationUrls.js";
5
5
  import { errorResult, textError, textResult } from "../lib/toolResult.js";
6
6
  import { httpUrlError } from "../lib/urls.js";
7
7
  import { resolveZone, zonePostbackNote } from "../lib/zones.js";
@@ -17,8 +17,9 @@ export const CREATE_CAMPAIGN_DESCRIPTION = "Create a campaign (offer) in the cur
17
17
  "create one first with create_team_member if needed. " +
18
18
  "Everything else gets media-buying defaults: CPA model, " +
19
19
  "rate 0 (no internal advertiser billing), created paused, a global payout rule when " +
20
- "payout is given, and a ready-to-use tracking link prefilled with the sub convention " +
21
- "(source_click_id + sub1..sub5, named by the tenant's sub labels). The campaign is " +
20
+ "payout is given, and a ready-to-use tracking link. A zone linked to a traffic source " +
21
+ "uses that source's tracking template; otherwise the link is prefilled with the sub " +
22
+ "convention (source_click_id + sub1..sub5, named by the tenant's sub labels). The campaign is " +
22
23
  "created paused, so activate it before sending traffic through either URL. " +
23
24
  "Geo whitelist applies to /serve rotation only; the tracking link itself is not geo-gated. " +
24
25
  "DRY-RUN by default; pass confirm=true to apply. No money is spent by this call; the " +
@@ -87,6 +88,7 @@ export async function createCampaign(client, config, args) {
87
88
  return textError(zoneResult.error);
88
89
  }
89
90
  const { zone, inactiveWarning } = zoneResult;
91
+ const linked = await resolveLinkedSource(client, zone, false);
90
92
  const name = args.name?.trim() || defaultName(offerUrl, geoCodes, args.payout);
91
93
  const geoNote = geoCodes.length
92
94
  ? `${geoCodes.join(", ")} (whitelist; enforced on /serve, not on the tracking link)`
@@ -105,6 +107,7 @@ export async function createCampaign(client, config, args) {
105
107
  `| Model | CPA, rate 0 (defaults — no internal advertiser billing) |`,
106
108
  `| Status | paused (activate before sending traffic through either URL) |`,
107
109
  `| Zone | ${mdCell(zone.name)} (\`${zone.id}\`) — ${zonePostbackNote(zone)} |`,
110
+ ...(linked.sourceLabel ? [`| Traffic source | ${linked.sourceLabel} |`] : []),
108
111
  ].join("\n");
109
112
  if (!args.confirm) {
110
113
  return textResult([
@@ -112,6 +115,7 @@ export async function createCampaign(client, config, args) {
112
115
  "",
113
116
  summaryTable,
114
117
  ...(inactiveWarning ? ["", inactiveWarning] : []),
118
+ ...linked.notes.flatMap((note) => ["", note]),
115
119
  "",
116
120
  "Call again with `confirm: true` to create it. The tracking link is returned after create.",
117
121
  ].join("\n"));
@@ -149,9 +153,11 @@ export async function createCampaign(client, config, args) {
149
153
  payoutNote = `⚠️ campaign created, but setting the payout rule failed: ${message}`;
150
154
  }
151
155
  }
152
- // 5. Ready-to-paste tracking link with the sub convention prefilled.
156
+ // 5. Ready-to-paste tracking link: linked source template when present,
157
+ // otherwise the generic sub convention.
153
158
  const trackingLink = buildTrackingLink(integration.baseUrl, created.id, zone.id, {
154
159
  subLabels: integration.subLabels,
160
+ template: linked.template,
155
161
  });
156
162
  return textResult([
157
163
  `✅ Campaign **${mdCell(created.name)}** created (id \`${created.id}\`).`,
@@ -165,16 +171,22 @@ export async function createCampaign(client, config, args) {
165
171
  `| Model | CPA, rate 0 (defaults — no internal advertiser billing) |`,
166
172
  `| Status | ${created.status || "paused"} (activate before sending traffic through either URL) |`,
167
173
  `| Zone | ${mdCell(zone.name)} (\`${zone.id}\`) — ${zonePostbackNote(zone)} |`,
174
+ ...(linked.sourceLabel ? [`| Traffic source | ${linked.sourceLabel} |`] : []),
168
175
  ...(inactiveWarning ? ["", inactiveWarning] : []),
176
+ ...linked.notes.flatMap((note) => ["", note]),
169
177
  "",
170
178
  "**Tracking link** (give this to the traffic source):",
171
179
  "```",
172
180
  trackingLink,
173
181
  "```",
174
- "Replace each `{…}` placeholder with the source's macro; `{clickid}` is already " +
175
- "correct for RichAds. Drop sub slots you don't need. Append `&cost={cost}` " +
176
- "(the network's cost macro) to import media cost for ROI. This link returns " +
177
- "404 while the campaign is paused; run it with `set_campaign_status` before use.",
182
+ linked.template !== undefined
183
+ ? "Query parameters come from the linked source's tracking template. Edit them " +
184
+ "with `update_traffic_source`. This link returns 404 while the campaign is " +
185
+ "paused; run it with `set_campaign_status` before use."
186
+ : "Replace each `{…}` placeholder with the source's macro and drop sub slots you " +
187
+ "don't need. Append `&cost=<network cost macro>` to import media cost for ROI. " +
188
+ "This link returns 404 while the campaign is paused; run it with " +
189
+ "`set_campaign_status` before use.",
178
190
  "",
179
191
  "_Only defaults were set — use `set_targeting_rule`, `set_payout_rule`, and budget fields on `update_campaign` for the rest._",
180
192
  "_Need this link again later, or the rotating /serve URL instead? `get_tracking_link` / `get_zone_url`._",
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { AffsetClient } from "../client.js";
4
+ import { TRAFFIC_SOURCE_STATUSES } from "../types.js";
5
+ export declare const CREATE_TRAFFIC_SOURCE_DESCRIPTION: string;
6
+ export declare const createTrafficSourceInputSchema: {
7
+ name: z.ZodString;
8
+ preset: z.ZodOptional<z.ZodString>;
9
+ tracking_template: z.ZodOptional<z.ZodString>;
10
+ postback_template: z.ZodOptional<z.ZodString>;
11
+ api_token: z.ZodOptional<z.ZodString>;
12
+ status: z.ZodOptional<z.ZodEnum<["active", "archived"]>>;
13
+ confirm: z.ZodDefault<z.ZodBoolean>;
14
+ };
15
+ type CreateTrafficSourceArgs = {
16
+ name: string;
17
+ preset?: string;
18
+ tracking_template?: string;
19
+ postback_template?: string;
20
+ api_token?: string;
21
+ status?: (typeof TRAFFIC_SOURCE_STATUSES)[number];
22
+ confirm: boolean;
23
+ };
24
+ export declare function createTrafficSource(client: AffsetClient, args: CreateTrafficSourceArgs): Promise<CallToolResult>;
25
+ export {};
@@ -0,0 +1,191 @@
1
+ import { z } from "zod";
2
+ import { mdCell } from "../lib/format.js";
3
+ import { SOURCE_CLICK_ID_PARAM, templateHasParam } from "../lib/integrationUrls.js";
4
+ import { errorResult, textError, textResult } from "../lib/toolResult.js";
5
+ import { httpUrlError } from "../lib/urls.js";
6
+ import { TRAFFIC_SOURCE_STATUSES, } from "../types.js";
7
+ const NAME_MAX = 200;
8
+ const TEMPLATE_MAX = 2000;
9
+ const TOKEN_MAX = 500;
10
+ export const CREATE_TRAFFIC_SOURCE_DESCRIPTION = "Create a traffic source — an ad network account bought from. Creating from a preset " +
11
+ "(exoclick, trafficstars, propellerads, adsterra, richads) copies the network's " +
12
+ "verified tracking + postback templates into an editable row; [BRACKETED] pieces in a " +
13
+ "postback template are account-specific values to fill in. Link zones to the source " +
14
+ "via create_zone/update_zone traffic_source_id, and get_zone_url renders its template. " +
15
+ "The api_token is stored write-only for upcoming cost sync. " +
16
+ "DRY-RUN by default; pass confirm=true to apply.";
17
+ export const createTrafficSourceInputSchema = {
18
+ name: z
19
+ .string()
20
+ .trim()
21
+ .min(1)
22
+ .max(NAME_MAX)
23
+ .describe('Display name, unique per tenant — e.g. "ExoClick — main".'),
24
+ preset: z
25
+ .string()
26
+ .trim()
27
+ .min(1)
28
+ .optional()
29
+ .describe("Network preset id to copy templates from (e.g. exoclick, trafficstars, " +
30
+ "propellerads, adsterra, richads). The row stays fully editable and remembers " +
31
+ "its preset. Omit for a custom source."),
32
+ tracking_template: z
33
+ .string()
34
+ .max(TEMPLATE_MAX)
35
+ .optional()
36
+ .describe("Query string appended to the zone URL — our param names on the left, the " +
37
+ "network's macros on the right. Overrides the preset's template. Must not start " +
38
+ "with ? or &."),
39
+ postback_template: z
40
+ .string()
41
+ .max(TEMPLATE_MAX)
42
+ .optional()
43
+ .describe("The network's S2S conversion endpoint using our postback macros " +
44
+ "({source_click_id}, {payout}). Overrides the preset's template. Prefills the " +
45
+ "linked zones' postback_url suggestion — never applied automatically."),
46
+ api_token: z
47
+ .string()
48
+ .min(1)
49
+ .max(TOKEN_MAX)
50
+ .optional()
51
+ .describe("Network API credential for upcoming cost sync. Stored write-only — reads only " +
52
+ "ever return has_api_token."),
53
+ status: z.enum(TRAFFIC_SOURCE_STATUSES).optional().describe("active (default) or archived."),
54
+ confirm: z
55
+ .boolean()
56
+ .default(false)
57
+ .describe("false = dry-run preview (default). true = create the traffic source."),
58
+ };
59
+ export async function createTrafficSource(client, args) {
60
+ try {
61
+ const trackingTemplate = args.tracking_template?.trim();
62
+ if (trackingTemplate && /^[?&]/.test(trackingTemplate)) {
63
+ return textError("tracking_template must not start with ? or & — it is appended after ? automatically.");
64
+ }
65
+ if (args.postback_template?.trim()) {
66
+ const err = httpUrlError(args.postback_template.trim(), "postback_template");
67
+ if (err)
68
+ return textError(err);
69
+ }
70
+ const apiToken = args.api_token?.trim();
71
+ if (args.api_token !== undefined && !apiToken) {
72
+ return textError("api_token must be a non-empty string.");
73
+ }
74
+ // The server copies preset templates on create; the catalog is fetched only
75
+ // to preview them in the dry run. An unreadable catalog is not an error —
76
+ // the server stays the arbiter of preset validity on the actual create.
77
+ const lookup = args.preset
78
+ ? await fetchPreset(client, args.preset)
79
+ : { state: "none" };
80
+ if (lookup.state === "unknown") {
81
+ const listed = lookup.ids.length > 0
82
+ ? `Valid ids: ${lookup.ids.map((id) => `\`${mdCell(id)}\``).join(", ")}.`
83
+ : "The preset catalog was empty.";
84
+ return textError(`Unknown preset \`${mdCell(args.preset)}\`. ${listed} Omit preset for a custom source.`);
85
+ }
86
+ const preset = lookup.state === "found" ? lookup.preset : undefined;
87
+ // Catalog ids are lowercase; send the canonical id when we have it so
88
+ // "ExoClick" still creates, including when the catalog itself is down.
89
+ const presetId = lookup.state === "found"
90
+ ? lookup.preset.id
91
+ : args.preset
92
+ ? args.preset.toLowerCase()
93
+ : undefined;
94
+ const presetFallbackCell = lookup.state === "unavailable" ? "_(copied from preset — catalog could not be read)_" : "—";
95
+ const effectiveTracking = trackingTemplate !== undefined ? trackingTemplate : (preset?.tracking_template ?? "");
96
+ const warnings = [];
97
+ if (effectiveTracking && !templateHasParam(effectiveTracking, SOURCE_CLICK_ID_PARAM)) {
98
+ warnings.push("⚠️ The tracking template has no `source_click_id=` parameter — the " +
99
+ "network's click token will not be captured on linked zones' URLs.");
100
+ }
101
+ const summaryTable = [
102
+ "| Field | Value |",
103
+ "|---|---|",
104
+ `| Name | ${mdCell(args.name)} |`,
105
+ `| Preset | ${presetId ? mdCell(presetId) : "— (custom)"} |`,
106
+ `| Tracking template | ${templateCell(trackingTemplate, preset?.tracking_template, presetFallbackCell)} |`,
107
+ `| Postback template | ${templateCell(args.postback_template?.trim(), preset?.postback_template, presetFallbackCell)} |`,
108
+ `| API token | ${apiToken ? "(set — stored write-only)" : "—"} |`,
109
+ `| Status | ${args.status ?? "active"} |`,
110
+ ...(preset?.notes ? [`| Preset notes | ${mdCell(preset.notes)} |`] : []),
111
+ ].join("\n");
112
+ if (!args.confirm) {
113
+ return textResult([
114
+ "**Dry run** — would create a traffic source with:",
115
+ "",
116
+ summaryTable,
117
+ ...(warnings.length ? ["", ...warnings] : []),
118
+ "",
119
+ "Call again with `confirm: true` to create it.",
120
+ ].join("\n"));
121
+ }
122
+ const body = { name: args.name };
123
+ if (presetId !== undefined)
124
+ body.preset = presetId;
125
+ if (args.tracking_template !== undefined)
126
+ body.tracking_template = trackingTemplate ?? "";
127
+ if (args.postback_template !== undefined) {
128
+ body.postback_template = args.postback_template.trim();
129
+ }
130
+ if (apiToken !== undefined)
131
+ body.api_token = apiToken;
132
+ if (args.status !== undefined)
133
+ body.status = args.status;
134
+ const created = await client.post("/api/traffic-sources", body);
135
+ // Echo the row the server actually stored (preset templates copied in).
136
+ // Non-fatal; fall back to the create summary.
137
+ let stored = null;
138
+ try {
139
+ stored = await client.get(`/api/traffic-sources/${encodeURIComponent(created.id)}`);
140
+ }
141
+ catch {
142
+ stored = null;
143
+ }
144
+ return textResult([
145
+ `✅ Traffic source **${mdCell(args.name)}** created (id \`${created.id}\`).`,
146
+ "",
147
+ stored
148
+ ? [
149
+ "| Field | Value |",
150
+ "|---|---|",
151
+ `| Preset | ${stored.preset ? mdCell(stored.preset) : "— (custom)"} |`,
152
+ `| Tracking template | ${stored.tracking_template ? mdCell(stored.tracking_template) : "—"} |`,
153
+ `| Postback template | ${stored.postback_template ? mdCell(stored.postback_template) : "—"} |`,
154
+ `| API token | ${stored.has_api_token ? "set (write-only)" : "—"} |`,
155
+ `| Status | ${mdCell(stored.status)} |`,
156
+ ].join("\n")
157
+ : summaryTable,
158
+ ...(warnings.length ? ["", ...warnings] : []),
159
+ "",
160
+ "Link zones to it with `create_zone`/`update_zone` `traffic_source_id`; " +
161
+ "`get_zone_url` then renders its tracking template. Replace any `[BRACKETED]` " +
162
+ "values in the postback template with your account's, then use it for the " +
163
+ "zone's postback_url.",
164
+ ].join("\n"));
165
+ }
166
+ catch (err) {
167
+ return errorResult(err);
168
+ }
169
+ }
170
+ function templateCell(explicit, presetValue, fallback) {
171
+ if (explicit !== undefined) {
172
+ return explicit ? mdCell(explicit) : "— _(explicitly empty)_";
173
+ }
174
+ if (presetValue)
175
+ return `${mdCell(presetValue)} _(copied from preset)_`;
176
+ return fallback;
177
+ }
178
+ async function fetchPreset(client, presetId) {
179
+ try {
180
+ const res = await client.get("/api/traffic-source-presets");
181
+ const presets = res.presets ?? [];
182
+ const preset = presets.find((p) => p.id.toLowerCase() === presetId.toLowerCase());
183
+ return preset
184
+ ? { state: "found", preset }
185
+ : { state: "unknown", ids: presets.map((p) => p.id) };
186
+ }
187
+ catch {
188
+ return { state: "unavailable" };
189
+ }
190
+ }
191
+ //# sourceMappingURL=createTrafficSource.js.map
@@ -8,6 +8,7 @@ export declare const createZoneInputSchema: {
8
8
  site_url: z.ZodOptional<z.ZodString>;
9
9
  traffic_back_url: z.ZodOptional<z.ZodString>;
10
10
  user_email: z.ZodOptional<z.ZodString>;
11
+ traffic_source_id: z.ZodOptional<z.ZodString>;
11
12
  confirm: z.ZodDefault<z.ZodBoolean>;
12
13
  };
13
14
  type CreateZoneArgs = {
@@ -16,6 +17,7 @@ type CreateZoneArgs = {
16
17
  site_url?: string;
17
18
  traffic_back_url?: string;
18
19
  user_email?: string;
20
+ traffic_source_id?: string;
19
21
  confirm: boolean;
20
22
  };
21
23
  export declare function createZone(client: AffsetClient, args: CreateZoneArgs): Promise<CallToolResult>;
@@ -4,7 +4,8 @@ import { errorResult, textError, textResult } from "../lib/toolResult.js";
4
4
  import { httpUrlError } from "../lib/urls.js";
5
5
  export const CREATE_ZONE_DESCRIPTION = "Create a traffic-source zone in the current namespace. Requires a name; optional " +
6
6
  "postback_url (where conversions are reported back — include {source_click_id}), " +
7
- "site_url, traffic_back_url, and user_email (publisher owner). Status is always " +
7
+ "site_url, traffic_back_url, user_email (publisher owner), and traffic_source_id " +
8
+ "(get_zone_url then renders that source's tracking template). Status is always " +
8
9
  "active on create. Counts against the plan's zone limit (402 if exceeded). " +
9
10
  "DRY-RUN by default; pass confirm=true to apply.";
10
11
  export const createZoneInputSchema = {
@@ -26,6 +27,13 @@ export const createZoneInputSchema = {
26
27
  .email()
27
28
  .optional()
28
29
  .describe("Optional publisher email to own this zone (owner/manager only)."),
30
+ traffic_source_id: z
31
+ .string()
32
+ .trim()
33
+ .min(1)
34
+ .optional()
35
+ .describe("Traffic source to link (see list_traffic_sources). Must be a source in this " +
36
+ "namespace. get_zone_url/get_tracking_link then render its tracking template."),
29
37
  confirm: z
30
38
  .boolean()
31
39
  .default(false)
@@ -62,6 +70,7 @@ export async function createZone(client, args) {
62
70
  `| Site | ${mdCell(args.site_url ?? "—")} |`,
63
71
  `| Traffic back | ${mdCell(args.traffic_back_url ?? "—")} |`,
64
72
  `| Publisher | ${mdCell(args.user_email ?? "—")} |`,
73
+ `| Traffic source | ${args.traffic_source_id ? `\`${mdCell(args.traffic_source_id)}\`` : "—"} |`,
65
74
  ].join("\n");
66
75
  if (!args.confirm) {
67
76
  return textResult([
@@ -81,6 +90,8 @@ export async function createZone(client, args) {
81
90
  body.traffic_back_url = args.traffic_back_url;
82
91
  if (args.user_email !== undefined)
83
92
  body.user_email = args.user_email;
93
+ if (args.traffic_source_id !== undefined)
94
+ body.traffic_source_id = args.traffic_source_id;
84
95
  const created = await client.post("/api/zones", body);
85
96
  // Create response is sparse — fetch full row for the echo.
86
97
  let zone = null;
@@ -106,6 +117,7 @@ export async function createZone(client, args) {
106
117
  `| Site | ${mdCell(zone?.site_url ?? args.site_url ?? "—")} |`,
107
118
  `| Traffic back | ${mdCell(zone?.traffic_back_url ?? args.traffic_back_url ?? "—")} |`,
108
119
  `| Publisher | ${mdCell(zone?.user_email ?? args.user_email ?? "—")} |`,
120
+ `| Traffic source | ${zone?.traffic_source_name ? mdCell(zone.traffic_source_name) : args.traffic_source_id ? `\`${mdCell(args.traffic_source_id)}\`` : "—"} |`,
109
121
  "",
110
122
  "Use this zone id with `create_campaign` (or pass it as zone_id) for tracking links.",
111
123
  ].join("\n"));
@@ -19,6 +19,7 @@ export declare const getStatsInputSchema: {
19
19
  conversion_type: z.ZodOptional<z.ZodString>;
20
20
  advertiser_email: z.ZodOptional<z.ZodString>;
21
21
  publisher_email: z.ZodOptional<z.ZodString>;
22
+ paid_only: z.ZodDefault<z.ZodBoolean>;
22
23
  };
23
24
  type GetStatsArgs = {
24
25
  group_by: GroupBy;
@@ -35,6 +36,7 @@ type GetStatsArgs = {
35
36
  conversion_type?: string;
36
37
  advertiser_email?: string;
37
38
  publisher_email?: string;
39
+ paid_only?: boolean;
38
40
  };
39
41
  export declare function getStats(client: AffsetClient, args: GetStatsArgs): Promise<CallToolResult>;
40
42
  export {};
@@ -6,12 +6,15 @@ import { GROUP_BY_VALUES, SUB_KEYS } from "../types.js";
6
6
  export const GET_STATS_DESCRIPTION = "Pull affset traffic stats grouped by a single dimension. Returns impressions, clicks, " +
7
7
  "conversions, CR, payout, media cost and ROI as a table. Drill down by calling " +
8
8
  "repeatedly: first group_by=date or campaign_id, then narrow with filters " +
9
- "(campaign_ids, zone_ids, sub1..sub5, conversion_type, advertiser_email, publisher_email) " +
10
- "and change group_by (zone_id, sub1, ...). " +
9
+ "(campaign_ids, zone_ids, sub1..sub5, conversion_type, advertiser_email, publisher_email, " +
10
+ "paid_only) and change group_by (zone_id, sub1, ...). " +
11
11
  'Sub columns are titled with the tenant\'s configured labels (e.g. "Zone (sub1)") ' +
12
12
  "when set; group_by/filters always take the raw subN key. " +
13
13
  "conversion_type only matches conversion rows, so filtering by it zeroes impressions, clicks " +
14
14
  "and media cost — it narrows to conversions of that type, not clicks that led to one. " +
15
+ "paid_only defaults to true (same as the dashboard): conversions and CR exclude informative " +
16
+ "pings whose pixel type missed payout_goal_type, so CR is not inflated above 100%. Set " +
17
+ "false for the raw unfiltered count. " +
15
18
  "ROI is blank until traffic cost has been imported for the slice. " +
16
19
  "group_by=advertiser_email / publisher_email break down by team member; the API limits them " +
17
20
  "to owner/manager plus the matching side's manager role (403 otherwise). " +
@@ -61,15 +64,27 @@ export const getStatsInputSchema = {
61
64
  .optional()
62
65
  .describe("Narrow to one publisher's zones, independent of group_by. Owner/manager: any publisher. " +
63
66
  "publisher_manager: only one of their own assigned publishers (else 403). Other roles: 403."),
67
+ paid_only: z
68
+ .boolean()
69
+ .default(true)
70
+ .describe("Drop informative conversions (postback_skipped=non_goal_type, e.g. lp_view pings that " +
71
+ "missed payout_goal_type) from the conversions count and CR. Silent conversions still " +
72
+ "count — not a payout>0 filter. Default true (matches the dashboard) so CR is not " +
73
+ "inflated above 100%. Set false for the raw count."),
64
74
  };
65
75
  export async function getStats(client, args) {
66
76
  try {
67
77
  const timeZone = await client.getTenantTimezone();
68
78
  const { from, to, label } = resolveRange(args.range, args.from, args.to, timeZone);
79
+ // The API accepts only the literal strings "true"/"false"; omitted = false.
80
+ // MCP (and the dashboard) default to true so CR isn't inflated by lp_view
81
+ // pings, so we always send the resolved value rather than omitting it.
82
+ const paidOnly = args.paid_only ?? true;
69
83
  const query = {
70
84
  from,
71
85
  to,
72
86
  group_by: args.group_by,
87
+ paid_only: String(paidOnly),
73
88
  };
74
89
  if (args.campaign_ids?.length)
75
90
  query.campaign_ids = args.campaign_ids.join(",");
@@ -91,11 +106,12 @@ export async function getStats(client, args) {
91
106
  const data = await client.get("/api/stats", query);
92
107
  const table = formatStatsTable(data.stats ?? [], args.group_by, data.sub_labels);
93
108
  const heading = groupHeader(args.group_by, data.sub_labels);
109
+ const paidNote = paidOnly ? "" : " (including informative conversions)";
94
110
  return {
95
111
  content: [
96
112
  {
97
113
  type: "text",
98
- text: `**Stats — ${label}, by ${heading}**\n\n${table}`,
114
+ text: `**Stats — ${label}, by ${heading}**${paidNote}\n\n${table}`,
99
115
  },
100
116
  ],
101
117
  };
@@ -1,15 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { AffsetApiError } from "../client.js";
3
3
  import { mdCell } from "../lib/format.js";
4
- import { buildTrackingLink, fetchTenantIntegration, subLegend } from "../lib/integrationUrls.js";
5
- import { collectSubs, linkInputSchema } from "../lib/linkArgs.js";
4
+ import { buildTrackingLink, fetchTenantIntegration, resolveLinkedSource, subLegend, templateHasParam, } from "../lib/integrationUrls.js";
5
+ import { collectSubs, hasExplicitLinkParams, linkInputSchema, } from "../lib/linkArgs.js";
6
6
  import { errorResult, textError, textResult } from "../lib/toolResult.js";
7
7
  import { resolveZone, zonePostbackNote } from "../lib/zones.js";
8
8
  export const GET_TRACKING_LINK_DESCRIPTION = "Get the tracking link for an existing campaign + zone — the /track/click link that goes " +
9
9
  "straight to that one campaign with no rotation or targeting checks. Both the campaign " +
10
10
  "and zone must be active for the public link to work. Same link create_campaign echoes on " +
11
11
  "create; this re-derives it later, for any " +
12
- "campaign, with whatever sub values you want. Uses the tenant's custom API domain when " +
12
+ "campaign, with whatever sub values you want. A zone linked to a traffic source renders " +
13
+ "that source's tracking template instead. Uses the tenant's custom API domain when " +
13
14
  "one is set. Read-only: builds the URL, changes nothing.";
14
15
  export const getTrackingLinkInputSchema = {
15
16
  campaign_id: z
@@ -45,12 +46,15 @@ export async function getTrackingLink(client, config, args) {
45
46
  const { campaign } = campaignResult;
46
47
  const { zone, inactiveWarning } = zoneResult;
47
48
  const subs = collectSubs(args);
49
+ const linked = await resolveLinkedSource(client, zone, hasExplicitLinkParams(args));
48
50
  const url = buildTrackingLink(integration.baseUrl, campaign.id, zone.id, {
49
51
  sourceClickId: args.source_click_id,
50
52
  subs,
51
53
  cost: args.cost,
52
54
  subLabels: integration.subLabels,
55
+ template: linked.template,
53
56
  });
57
+ const templated = linked.template !== undefined;
54
58
  const legend = subLegend(integration.subLabels, subs);
55
59
  const statusNote = campaign.status === "active"
56
60
  ? "active — serving still depends on dates and budget state; also a /serve candidate"
@@ -67,6 +71,7 @@ export async function getTrackingLink(client, config, args) {
67
71
  "",
68
72
  "| Field | Value |",
69
73
  "|---|---|",
74
+ ...(linked.sourceLabel ? [`| Traffic source | ${linked.sourceLabel} |`] : []),
70
75
  `| Campaign status | ${statusNote} |`,
71
76
  `| Offer URL | ${mdCell(campaign.redirect_url ?? "—")} |`,
72
77
  `| Zone status | ${zone.status} |`,
@@ -74,13 +79,16 @@ export async function getTrackingLink(client, config, args) {
74
79
  ...(legend ? [`| Sub slots | ${legend} |`] : []),
75
80
  ...(availabilityWarnings.length ? ["", ...availabilityWarnings] : []),
76
81
  ...(inactiveWarning ? ["", inactiveWarning] : []),
82
+ ...linked.notes.flatMap((note) => ["", note]),
77
83
  "",
78
- "Replace each `{…}` placeholder with the source's own macro and drop the sub slots " +
79
- "you don't need. Values are inserted verbatim — the network expands its macros " +
80
- "before the request reaches affset.",
81
- args.cost
82
- ? "`cost` is recorded on the click row here. Use it on this link **or** on a zone URL for the same traffic, never both."
83
- : "Add `cost=<network cost macro>` to import media cost and get ROI in `get_stats`.",
84
+ templated
85
+ ? "Query parameters come from the linked source's tracking template — the network " +
86
+ "expands its macros before the request reaches affset. Edit them with " +
87
+ "`update_traffic_source`; explicit link parameters on this tool override the template."
88
+ : "Replace each `{…}` placeholder with the source's own macro and drop the sub slots " +
89
+ "you don't need. Values are inserted verbatim the network expands its macros " +
90
+ "before the request reaches affset.",
91
+ costNote(templated, linked.template, args.cost),
84
92
  "",
85
93
  "_Geo and other targeting rules are enforced in /serve rotation only — this link is " +
86
94
  "not geo-gated. Use `get_zone_url` when you want affset to pick the campaign._",
@@ -90,6 +98,15 @@ export async function getTrackingLink(client, config, args) {
90
98
  return errorResult(err);
91
99
  }
92
100
  }
101
+ function costNote(templated, template, costArg) {
102
+ const hasCost = templated ? templateHasParam(template, "cost") : Boolean(costArg);
103
+ if (hasCost) {
104
+ return "`cost` is recorded on the click row here. Use it on this link **or** on a zone URL for the same traffic, never both.";
105
+ }
106
+ return templated
107
+ ? "Add a `cost=<network cost macro>` parameter to the source's tracking template to import media cost and get ROI in `get_stats`."
108
+ : "Add `cost=<network cost macro>` to import media cost and get ROI in `get_stats`.";
109
+ }
93
110
  function campaignAvailabilityWarnings(campaign) {
94
111
  const warnings = [];
95
112
  const now = Date.now();
@@ -1,15 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { mdCell } from "../lib/format.js";
3
- import { buildZoneUrl, fetchTenantIntegration, subLegend } from "../lib/integrationUrls.js";
4
- import { collectSubs, linkInputSchema } from "../lib/linkArgs.js";
3
+ import { buildZoneUrl, fetchTenantIntegration, resolveLinkedSource, subLegend, templateHasParam, } from "../lib/integrationUrls.js";
4
+ import { collectSubs, hasExplicitLinkParams, linkInputSchema, } from "../lib/linkArgs.js";
5
5
  import { errorResult, textError, textResult } from "../lib/toolResult.js";
6
6
  import { resolveZone, zonePostbackNote } from "../lib/zones.js";
7
7
  export const GET_ZONE_URL_DESCRIPTION = "Get the zone URL to paste into a traffic source's campaign settings — the /serve link " +
8
- "that rotates across the zone's active campaigns. Prefilled with the sub convention " +
9
- "(source_click_id + sub1..sub5) and optionally the network's cost macro. Uses the " +
10
- "tenant's custom API domain when one is set. Read-only: builds the URL, changes nothing. " +
11
- "For a link straight to one active campaign without targeting checks, use " +
12
- "get_tracking_link instead.";
8
+ "that rotates across the zone's active campaigns. A zone linked to a traffic source " +
9
+ "renders that source's tracking template; otherwise the URL is prefilled with the sub " +
10
+ "convention (source_click_id + sub1..sub5) and optionally the network's cost macro. " +
11
+ "Uses the tenant's custom API domain when one is set. Read-only: builds the URL, " +
12
+ "changes nothing. For a link straight to one active campaign without targeting checks, " +
13
+ "use get_tracking_link instead.";
13
14
  export const getZoneUrlInputSchema = {
14
15
  zone_id: z
15
16
  .string()
@@ -30,16 +31,19 @@ export async function getZoneUrl(client, config, args) {
30
31
  return textError(zoneResult.error);
31
32
  const { zone, inactiveWarning } = zoneResult;
32
33
  const subs = collectSubs(args);
34
+ const linked = await resolveLinkedSource(client, zone, hasExplicitLinkParams(args));
33
35
  const url = buildZoneUrl(integration.baseUrl, zone.id, {
34
36
  sourceClickId: args.source_click_id,
35
37
  subs,
36
38
  cost: args.cost,
37
39
  subLabels: integration.subLabels,
40
+ template: linked.template,
38
41
  });
39
42
  // A zone URL with nothing to rotate serves the traffic-back / unsold path — the
40
43
  // single most common "my link doesn't work" report, so check it up front.
41
44
  const rotationNote = await describeRotation(client);
42
45
  const legend = subLegend(integration.subLabels, subs);
46
+ const templated = linked.template !== undefined;
43
47
  return textResult([
44
48
  `**Zone URL** for **${mdCell(zone.name)}** (\`${zone.id}\`) — give this to the traffic source:`,
45
49
  "",
@@ -49,19 +53,23 @@ export async function getZoneUrl(client, config, args) {
49
53
  "",
50
54
  "| Field | Value |",
51
55
  "|---|---|",
56
+ ...(linked.sourceLabel ? [`| Traffic source | ${linked.sourceLabel} |`] : []),
52
57
  `| Zone status | ${zone.status} |`,
53
58
  `| Rotation | ${rotationNote} |`,
54
59
  `| Postback | ${zonePostbackNote(zone)} |`,
55
60
  `| Traffic back | ${mdCell(zone.traffic_back_url ?? "—")} |`,
56
61
  ...(legend ? [`| Sub slots | ${legend} |`] : []),
57
62
  ...(inactiveWarning ? ["", inactiveWarning] : []),
63
+ ...linked.notes.flatMap((note) => ["", note]),
58
64
  "",
59
- "Replace each `{…}` placeholder with the source's own macro and drop the sub slots " +
60
- "you don't need. Values are inserted verbatim — the network expands its macros " +
61
- "before the request reaches affset.",
62
- args.cost
63
- ? "`cost` is recorded once per /serve, on the impression row — don't also add it to a tracking link for the same traffic."
64
- : "Add `cost=<network cost macro>` to import media cost and get ROI in `get_stats`.",
65
+ templated
66
+ ? "Query parameters come from the linked source's tracking template — the network " +
67
+ "expands its macros before the request reaches affset. Edit them with " +
68
+ "`update_traffic_source`; explicit link parameters on this tool override the template."
69
+ : "Replace each `{…}` placeholder with the source's own macro and drop the sub slots " +
70
+ "you don't need. Values are inserted verbatim the network expands its macros " +
71
+ "before the request reaches affset.",
72
+ costNote(templated, linked.template, args.cost),
65
73
  "",
66
74
  "_This URL rotates across the zone's **active** campaigns. For a link that goes " +
67
75
  "straight to one active campaign without applying targeting rules, use `get_tracking_link`._",
@@ -71,6 +79,15 @@ export async function getZoneUrl(client, config, args) {
71
79
  return errorResult(err);
72
80
  }
73
81
  }
82
+ function costNote(templated, template, costArg) {
83
+ const hasCost = templated ? templateHasParam(template, "cost") : Boolean(costArg);
84
+ if (hasCost) {
85
+ return "`cost` is recorded once per /serve, on the impression row — don't also add it to a tracking link for the same traffic.";
86
+ }
87
+ return templated
88
+ ? "Add a `cost=<network cost macro>` parameter to the source's tracking template to import media cost and get ROI in `get_stats`."
89
+ : "Add `cost=<network cost macro>` to import media cost and get ROI in `get_stats`.";
90
+ }
74
91
  /** Cheap active-campaign count; degrades to a neutral note rather than failing. */
75
92
  async function describeRotation(client) {
76
93
  try {
@@ -8,6 +8,7 @@ export declare const listConversionsInputSchema: {
8
8
  offset: z.ZodDefault<z.ZodNumber>;
9
9
  sort: z.ZodDefault<z.ZodEnum<["created_at", "ad_event_id", "click_id"]>>;
10
10
  order: z.ZodDefault<z.ZodEnum<["asc", "desc"]>>;
11
+ paid_only: z.ZodOptional<z.ZodBoolean>;
11
12
  click_id: z.ZodOptional<z.ZodString>;
12
13
  source_click_id: z.ZodOptional<z.ZodString>;
13
14
  type: z.ZodOptional<z.ZodString>;
@@ -19,6 +20,7 @@ type ListConversionsArgs = {
19
20
  offset: number;
20
21
  sort: (typeof SORT_FIELDS)[number];
21
22
  order: "asc" | "desc";
23
+ paid_only?: boolean;
22
24
  click_id?: string;
23
25
  source_click_id?: string;
24
26
  type?: string;