@affset/mcp 0.2.0 → 0.4.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,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;
@@ -6,14 +6,26 @@ import { SUB_KEYS, } from "../types.js";
6
6
  const SORT_FIELDS = ["created_at", "ad_event_id", "click_id"];
7
7
  export const LIST_CONVERSIONS_DESCRIPTION = "List recent conversion records (audit trail) for debugging payouts and pixel params. " +
8
8
  "Shows payout, spend, pixel `type`, source_click_id, click_id, subs, postback outcome, " +
9
- "and the raw payload. API supports pagination/sort only optional click_id / " +
10
- "source_click_id / type / payload_contains / zero_payout filters run client-side on " +
11
- "the current page. Does not include campaign_id/zone_id (not returned by the API).";
9
+ "and the raw payload. `paid_only: true` drops informative conversions server-side " +
10
+ "rows recorded with postback_skipped=non_goal_type because the pixel type missed the " +
11
+ "campaign's payout_goal_type. Silent conversions and other skip reasons still come back " +
12
+ "(not a payout>0 filter). Default false, all rows. Beyond pagination/sort/paid_only, " +
13
+ "the optional click_id / source_click_id / type / payload_contains / zero_payout filters " +
14
+ "run client-side on the current page. Does not include campaign_id/zone_id (not returned " +
15
+ "by the API).";
12
16
  export const listConversionsInputSchema = {
13
17
  limit: z.number().int().min(1).max(100).default(20).describe("Page size (1–100). Default 20."),
14
18
  offset: z.number().int().min(0).default(0).describe("Pagination offset. Default 0."),
15
19
  sort: z.enum(SORT_FIELDS).default("created_at").describe("Sort field. Default created_at."),
16
20
  order: z.enum(["asc", "desc"]).default("desc").describe("Sort order. Default desc."),
21
+ paid_only: z
22
+ .boolean()
23
+ .optional()
24
+ .describe("true drops informative conversions — rows recorded with " +
25
+ "postback_skipped=non_goal_type because the pixel type missed the campaign's " +
26
+ "payout_goal_type. Silent conversions and other skip reasons still come back " +
27
+ "(not a payout>0 filter). Server-side (filters the whole dataset, not just this page). " +
28
+ "Works without payout visibility. Default false (all rows)."),
17
29
  click_id: z
18
30
  .string()
19
31
  .min(1)
@@ -42,13 +54,17 @@ export const listConversionsInputSchema = {
42
54
  };
43
55
  export async function listConversions(client, args) {
44
56
  try {
57
+ const query = {
58
+ limit: args.limit,
59
+ offset: args.offset,
60
+ sort: args.sort,
61
+ order: args.order,
62
+ };
63
+ // The API accepts only the literal strings "true"/"false"; omitted = false.
64
+ if (args.paid_only !== undefined)
65
+ query.paid_only = String(args.paid_only);
45
66
  const [data, settings] = await Promise.all([
46
- client.get("/api/conversions", {
47
- limit: args.limit,
48
- offset: args.offset,
49
- sort: args.sort,
50
- order: args.order,
51
- }),
67
+ client.get("/api/conversions", query),
52
68
  // One read covers both the sub labels and the zone timestamps are shown in.
53
69
  client.get("/api/tenant").catch(() => ({})),
54
70
  ]);
@@ -67,6 +83,8 @@ export async function listConversions(client, args) {
67
83
  const total = pagination?.total ?? all.length;
68
84
  const clientFiltered = filtered.length !== all.length;
69
85
  const filterBits = [];
86
+ if (args.paid_only)
87
+ filterBits.push("paid_only");
70
88
  if (args.click_id)
71
89
  filterBits.push(`click_id=${args.click_id}`);
72
90
  if (args.source_click_id)
@@ -98,7 +116,8 @@ export async function listConversions(client, args) {
98
116
  ]
99
117
  : []),
100
118
  "",
101
- "_API has no campaign/zone/date filters page with limit/offset, or filter this page._",
119
+ "_API has no campaign/zone/date filters. `paid_only` is server-side; other optional " +
120
+ "filters apply to this page. Page with limit/offset._",
102
121
  payoutHidden
103
122
  ? "_`payout` is hidden for this role — the column shows `—` for every row._"
104
123
  : "_`$0` payout with a non-empty type often means payout_goal_type mismatch or no payout rule._",
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { type AffsetClient } from "../client.js";
4
+ export declare const LIST_SOURCE_BIDS_DESCRIPTION: string;
5
+ export declare const listSourceBidsInputSchema: {
6
+ traffic_source_id: z.ZodString;
7
+ };
8
+ type ListSourceBidsArgs = {
9
+ traffic_source_id: string;
10
+ };
11
+ export declare function listSourceBids(client: AffsetClient, args: ListSourceBidsArgs): Promise<CallToolResult>;
12
+ export {};
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ import { AffsetApiError } from "../client.js";
3
+ import { formatBidsTable, LARGE_INCREASE_FACTOR, networkErrorText } from "../lib/bids.js";
4
+ import { errorResult, textError, textResult } from "../lib/toolResult.js";
5
+ export const LIST_SOURCE_BIDS_DESCRIPTION = "List the network campaigns of one traffic source with their CURRENT bids, read live " +
6
+ "from the network account (ExoClick, TrafficStars or RichAds preset with an API token). " +
7
+ "Shows status, pricing model (CPC/CPM/…), the bid in USD, and the last bid change that " +
8
+ "entered Affset's mutation stage. Use before set_source_bid to see what a campaign bids " +
9
+ "today. Read-only.";
10
+ export const listSourceBidsInputSchema = {
11
+ traffic_source_id: z
12
+ .string()
13
+ .trim()
14
+ .min(1)
15
+ .describe("Traffic source id (from list_traffic_sources) — must have a network API token."),
16
+ };
17
+ export async function listSourceBids(client, args) {
18
+ try {
19
+ let res;
20
+ try {
21
+ res = await client.get(`/api/traffic-sources/${encodeURIComponent(args.traffic_source_id)}/bids`);
22
+ }
23
+ catch (err) {
24
+ if (err instanceof AffsetApiError && err.status === 404) {
25
+ return textError(`Traffic source \`${args.traffic_source_id}\` not found in this namespace.`);
26
+ }
27
+ if (err instanceof AffsetApiError && err.status === 422) {
28
+ return textError(`${err.message} Bids can be read for sources created from the exoclick, trafficstars ` +
29
+ "or richads preset once an api_token is stored (update_traffic_source).");
30
+ }
31
+ if (err instanceof AffsetApiError && err.status === 502) {
32
+ return textError(`The network refused the request: ${networkErrorText(err.message)}`);
33
+ }
34
+ throw err;
35
+ }
36
+ return textResult([
37
+ `**Network campaigns and bids** (${res.campaigns.length}, read from the network at ` +
38
+ `${new Date(res.fetched_at).toISOString()}):`,
39
+ "",
40
+ formatBidsTable(res.campaigns),
41
+ "",
42
+ "_Bids are in USD: CPM per 1,000 impressions, CPC per click. Change one with " +
43
+ "`set_source_bid` (dry-run first). Increases above " +
44
+ `${LARGE_INCREASE_FACTOR}× the current bid need \`allow_large_increase: true\`._`,
45
+ ].join("\n"));
46
+ }
47
+ catch (err) {
48
+ return errorResult(err);
49
+ }
50
+ }
51
+ //# sourceMappingURL=listSourceBids.js.map
@@ -0,0 +1,17 @@
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 LIST_TRAFFIC_SOURCES_DESCRIPTION: string;
6
+ export declare const listTrafficSourcesInputSchema: {
7
+ status: z.ZodOptional<z.ZodEnum<["active", "archived"]>>;
8
+ limit: z.ZodDefault<z.ZodNumber>;
9
+ offset: z.ZodDefault<z.ZodNumber>;
10
+ };
11
+ type ListTrafficSourcesArgs = {
12
+ status?: (typeof TRAFFIC_SOURCE_STATUSES)[number];
13
+ limit: number;
14
+ offset: number;
15
+ };
16
+ export declare function listTrafficSources(client: AffsetClient, args: ListTrafficSourcesArgs): Promise<CallToolResult>;
17
+ export {};
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import { capUntrusted, mdCell } from "../lib/format.js";
3
+ import { errorResult, textResult } from "../lib/toolResult.js";
4
+ import { TRAFFIC_SOURCE_STATUSES } from "../types.js";
5
+ /** Templates run to 2000 chars server-side; the table shows enough to identify one. */
6
+ const TEMPLATE_PREVIEW_MAX = 150;
7
+ export const LIST_TRAFFIC_SOURCES_DESCRIPTION = "List the tenant's traffic sources — the ad networks bought from, each with the " +
8
+ "tracking template its linked zones render in get_zone_url/get_tracking_link and an " +
9
+ "optional postback template. The stored network API token is write-only and shown " +
10
+ "only as set/none. Read-only.";
11
+ export const listTrafficSourcesInputSchema = {
12
+ status: z
13
+ .enum(TRAFFIC_SOURCE_STATUSES)
14
+ .optional()
15
+ .describe("Filter by status. Omit for all sources."),
16
+ limit: z.number().int().min(1).max(100).default(50).describe("Sources per page (1–100)."),
17
+ offset: z.number().int().min(0).default(0).describe("Pagination offset."),
18
+ };
19
+ export async function listTrafficSources(client, args) {
20
+ try {
21
+ const query = {
22
+ limit: args.limit,
23
+ offset: args.offset,
24
+ sort: "created_at",
25
+ order: "desc",
26
+ };
27
+ if (args.status)
28
+ query.status = args.status;
29
+ const res = await client.get("/api/traffic-sources", query);
30
+ const sources = res.traffic_sources ?? [];
31
+ const total = res.pagination?.total ?? sources.length;
32
+ if (sources.length === 0) {
33
+ return textResult(args.status
34
+ ? `No ${args.status} traffic sources.`
35
+ : "No traffic sources yet. Create one with `create_traffic_source` — start from " +
36
+ "a preset to get the network's tracking template ready-made, then link zones " +
37
+ "to it via `create_zone`/`update_zone` `traffic_source_id`.");
38
+ }
39
+ const rows = sources.map((s) => [
40
+ `| ${mdCell(s.name)} `,
41
+ `| \`${mdCell(s.id)}\` `,
42
+ `| ${s.preset ? mdCell(s.preset) : "—"} `,
43
+ `| ${mdCell(s.status)} `,
44
+ `| ${s.has_api_token ? "set" : "—"} `,
45
+ `| ${s.tracking_template ? mdCell(capUntrusted(s.tracking_template, TEMPLATE_PREVIEW_MAX)) : "—"} `,
46
+ `| ${s.postback_template ? mdCell(capUntrusted(s.postback_template, TEMPLATE_PREVIEW_MAX)) : "—"} |`,
47
+ ].join(""));
48
+ return textResult([
49
+ `**Traffic sources** (${sources.length} of ${total}):`,
50
+ "",
51
+ "| Name | Id | Preset | Status | API token | Tracking template | Postback template |",
52
+ "|---|---|---|---|---|---|---|",
53
+ ...rows,
54
+ "",
55
+ "_Zones linked to a source render its tracking template in `get_zone_url` / " +
56
+ "`get_tracking_link`. Edit a source with `update_traffic_source`._",
57
+ ].join("\n"));
58
+ }
59
+ catch (err) {
60
+ return errorResult(err);
61
+ }
62
+ }
63
+ //# sourceMappingURL=listTrafficSources.js.map
@@ -3,8 +3,8 @@ import { mdCell } from "../lib/format.js";
3
3
  import { errorResult } from "../lib/toolResult.js";
4
4
  import { ZONE_STATUSES } from "../types.js";
5
5
  export const LIST_ZONES_DESCRIPTION = "List traffic-source zones in the current namespace. Filter by status and optionally " +
6
- "by name (client-side contains match). Returns id, name, status, postback_url, " +
7
- "site_url, publisher. Paginated (default 20, max 100).";
6
+ "by name (client-side contains match). Returns id, name, linked traffic source, " +
7
+ "status, postback_url, site_url, publisher. Paginated (default 20, max 100).";
8
8
  export const listZonesInputSchema = {
9
9
  status: z.enum(ZONE_STATUSES).optional().describe("Filter by zone status."),
10
10
  name_contains: z
@@ -54,11 +54,16 @@ function renderTable(zones) {
54
54
  if (zones.length === 0)
55
55
  return "_No zones matched._";
56
56
  const lines = [
57
- "| ID | Name | Status | Postback | Site | Publisher |",
58
- "|---|---|---|---|---|---|",
57
+ "| ID | Name | Source | Status | Postback | Site | Publisher |",
58
+ "|---|---|---|---|---|---|---|",
59
59
  ];
60
60
  for (const z of zones) {
61
- lines.push(`| \`${z.id}\` | ${mdCell(z.name)} | ${z.status} | ${z.postback_url ? mdCell(shortUrl(z.postback_url)) : "⚠️ none"} | ${mdCell(shortUrl(z.site_url))} | ${mdCell(z.user_email ?? "—")} |`);
61
+ const source = z.traffic_source_name
62
+ ? mdCell(z.traffic_source_name)
63
+ : z.traffic_source_id
64
+ ? `\`${mdCell(z.traffic_source_id)}\``
65
+ : "—";
66
+ lines.push(`| \`${z.id}\` | ${mdCell(z.name)} | ${source} | ${z.status} | ${z.postback_url ? mdCell(shortUrl(z.postback_url)) : "⚠️ none"} | ${mdCell(shortUrl(z.site_url))} | ${mdCell(z.user_email ?? "—")} |`);
62
67
  }
63
68
  return lines.join("\n");
64
69
  }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { type AffsetClient } from "../client.js";
4
+ export declare const SET_SOURCE_BID_DESCRIPTION: string;
5
+ export declare const setSourceBidInputSchema: {
6
+ traffic_source_id: z.ZodString;
7
+ network_campaign_id: z.ZodEffects<z.ZodString, string, string>;
8
+ bid: z.ZodEffects<z.ZodNumber, number, number>;
9
+ expected_current_bid: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
10
+ allow_large_increase: z.ZodDefault<z.ZodBoolean>;
11
+ confirm: z.ZodDefault<z.ZodBoolean>;
12
+ };
13
+ type SetSourceBidArgs = {
14
+ traffic_source_id: string;
15
+ network_campaign_id: string;
16
+ bid: number;
17
+ expected_current_bid?: number | null;
18
+ allow_large_increase: boolean;
19
+ confirm: boolean;
20
+ };
21
+ export declare function setSourceBid(client: AffsetClient, args: SetSourceBidArgs): Promise<CallToolResult>;
22
+ export {};
@@ -0,0 +1,195 @@
1
+ import { z } from "zod";
2
+ import { AffsetApiError } from "../client.js";
3
+ import { BID_DECIMALS, bidsMatch, describeBidChange, formatBid, isLargeIncrease, LARGE_INCREASE_FACTOR, NETWORK_NAME_MAX, networkErrorText, pricingModelLabel, } from "../lib/bids.js";
4
+ import { capUntrusted, mdCell } from "../lib/format.js";
5
+ import { errorResult, textError, textResult } from "../lib/toolResult.js";
6
+ /** Mirrors the API (docs/api.md § Set bid). */
7
+ const MAX_BID = 1000;
8
+ const MIN_BID = 10 ** -BID_DECIMALS;
9
+ /** Error responses may include the reserved audit row, even if finalization failed. */
10
+ const bidErrorDetailsSchema = z.object({
11
+ code: z.string().optional(),
12
+ change: z
13
+ .object({ id: z.string(), status: z.enum(["pending", "applied", "failed"]) })
14
+ .nullable()
15
+ .optional(),
16
+ });
17
+ export const SET_SOURCE_BID_DESCRIPTION = "Set one network campaign's bid at the traffic source's network account (ExoClick, " +
18
+ "TrafficStars or RichAds). The bid is in USD using the campaign's existing pricing model " +
19
+ "(for RichAds: CPM for pops, CPC for push/display), and the network applies its own minimum. " +
20
+ "The write is recorded in the source's bid " +
21
+ "history and reported as applied only when the network echoes the new value. Raising a " +
22
+ `bid above ${LARGE_INCREASE_FACTOR}× the current one is refused unless allow_large_increase ` +
23
+ "is true. The confirmed write includes the live bid just reviewed, so a concurrent network " +
24
+ "change is refused instead of overwritten. DRY-RUN by default; pass confirm=true to apply.";
25
+ export const setSourceBidInputSchema = {
26
+ traffic_source_id: z
27
+ .string()
28
+ .trim()
29
+ .min(1)
30
+ .describe("Traffic source id (from list_traffic_sources)."),
31
+ network_campaign_id: z
32
+ .string()
33
+ .trim()
34
+ .regex(/^\d{1,19}$/, "network_campaign_id must be the network's numeric campaign id")
35
+ .refine((value) => Number.isSafeInteger(Number(value)) && Number(value) > 0, "network_campaign_id must be a positive numeric campaign id within the supported range")
36
+ .describe("The network's numeric campaign id — the value in sub4, or from list_source_bids."),
37
+ bid: z
38
+ .number()
39
+ .min(MIN_BID)
40
+ .max(MAX_BID)
41
+ .refine((value) => Math.abs(value * 10 ** BID_DECIMALS - Math.round(value * 10 ** BID_DECIMALS)) <= 1e-6, `bid must have at most ${BID_DECIMALS} decimal places`)
42
+ .describe(`New bid in USD (at most ${BID_DECIMALS} decimals, at most ${MAX_BID}).`),
43
+ expected_current_bid: z
44
+ .number()
45
+ .finite()
46
+ .nonnegative()
47
+ .nullable()
48
+ .optional()
49
+ .describe("The current bid shown by this tool's dry run (including null). Required with confirm=true " +
50
+ "so a bid that changed after review cannot be overwritten."),
51
+ allow_large_increase: z
52
+ .boolean()
53
+ .default(false)
54
+ .describe(`Required to raise a bid above ${LARGE_INCREASE_FACTOR}× its current value. ` +
55
+ "Only pass it after the operator has seen the current bid and asked for the increase."),
56
+ confirm: z
57
+ .boolean()
58
+ .default(false)
59
+ .describe("false = dry-run preview (default). true = send the bid to the network."),
60
+ };
61
+ function campaignLine(campaign) {
62
+ const name = campaign.name ? mdCell(capUntrusted(campaign.name, NETWORK_NAME_MAX)) : "(unnamed)";
63
+ return (`**${name}** (\`${mdCell(campaign.network_campaign_id)}\`, ${campaign.status}, ` +
64
+ `${pricingModelLabel(campaign.pricing_model)})`);
65
+ }
66
+ export async function setSourceBid(client, args) {
67
+ try {
68
+ const scaled = args.bid * 10 ** BID_DECIMALS;
69
+ if (!Number.isFinite(args.bid) || args.bid < MIN_BID || args.bid > MAX_BID) {
70
+ return textError(`bid must be between ${MIN_BID} and ${MAX_BID} USD.`);
71
+ }
72
+ if (Math.abs(scaled - Math.round(scaled)) > 1e-6) {
73
+ return textError(`bid must have at most ${BID_DECIMALS} decimal places.`);
74
+ }
75
+ if (args.confirm && args.expected_current_bid === undefined) {
76
+ return textError("Not applied: confirm=true requires expected_current_bid from this tool's dry run. " +
77
+ "Run the dry run again, review the current bid, then pass back the value it shows.");
78
+ }
79
+ const sourcePath = `/api/traffic-sources/${encodeURIComponent(args.traffic_source_id)}`;
80
+ const campaignId = String(Number(args.network_campaign_id));
81
+ // Read first, always: the operator confirms against the bid the network holds
82
+ // right now, not against a remembered number.
83
+ let current;
84
+ try {
85
+ const bids = await client.get(`${sourcePath}/bids`);
86
+ current = bids.campaigns.find((campaign) => campaign.network_campaign_id === campaignId);
87
+ }
88
+ catch (err) {
89
+ if (err instanceof AffsetApiError && err.status === 404) {
90
+ return textError(`Traffic source \`${args.traffic_source_id}\` not found in this namespace.`);
91
+ }
92
+ if (err instanceof AffsetApiError && (err.status === 422 || err.status === 502)) {
93
+ return textError(err.status === 422
94
+ ? `${err.message} Bids need an exoclick, trafficstars or richads source with an api_token.`
95
+ : `The network refused the request: ${networkErrorText(err.message)}`);
96
+ }
97
+ throw err;
98
+ }
99
+ if (current === undefined) {
100
+ return textError(`Campaign \`${campaignId}\` is not in this network account (list_source_bids shows the ` +
101
+ "campaigns it has). Check the sub4 value against the source's tracking template.");
102
+ }
103
+ const large = isLargeIncrease(current.bid, args.bid);
104
+ const lines = [`${campaignLine(current)}: ${describeBidChange(current.bid, args.bid)}`];
105
+ if (bidsMatch(current.bid, args.bid)) {
106
+ return textResult(`Nothing to change — ${campaignLine(current)} already bids ${formatBid(current.bid)}.`);
107
+ }
108
+ if (large) {
109
+ lines.push("", `⚠️ This raises the bid more than ${LARGE_INCREASE_FACTOR}× (${formatBid(current.bid)} → ` +
110
+ `${formatBid(args.bid)}). The API refuses it unless \`allow_large_increase: true\` ` +
111
+ `is passed${args.allow_large_increase ? " — it is." : "."}`);
112
+ }
113
+ if (!args.confirm) {
114
+ const reviewedBid = current.bid === null ? "null" : String(current.bid);
115
+ return textResult([
116
+ `**Dry run** — would set the bid at traffic source \`${args.traffic_source_id}\`.`,
117
+ "",
118
+ ...lines,
119
+ "",
120
+ large && !args.allow_large_increase
121
+ ? "Call again with `confirm: true`, " +
122
+ `\`expected_current_bid: ${reviewedBid}\`, **and** ` +
123
+ "`allow_large_increase: true` to apply."
124
+ : `Call again with \`confirm: true\` and \`expected_current_bid: ${reviewedBid}\` to apply.`,
125
+ ].join("\n"));
126
+ }
127
+ // The early confirmation check above narrows this to number|null here.
128
+ const reviewedBid = args.expected_current_bid;
129
+ if (!bidsMatch(reviewedBid, current.bid)) {
130
+ return textError(`Not applied: the campaign bid changed after review (${formatBid(reviewedBid)} → ` +
131
+ `${formatBid(current.bid)}). Run the dry run again and review the new current bid.`);
132
+ }
133
+ let applied;
134
+ try {
135
+ applied = await client.post(`${sourcePath}/bids`, {
136
+ network_campaign_id: campaignId,
137
+ bid: args.bid,
138
+ expected_current_bid: reviewedBid,
139
+ ...(args.allow_large_increase ? { allow_large_increase: true } : {}),
140
+ });
141
+ }
142
+ catch (err) {
143
+ // Once POST starts, a lost response or failed verification is not proof
144
+ // that the network kept the previous bid. Read live state before retrying.
145
+ if (!(err instanceof AffsetApiError) || err.status === 0 || err.status >= 500) {
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ return textError(`Bid write outcome is unconfirmed: ${networkErrorText(message)} ` +
148
+ "The network may have applied the bid. Check list_source_bids for the current bid " +
149
+ "before retrying.");
150
+ }
151
+ const parsed = bidErrorDetailsSchema.safeParse(err.body);
152
+ const details = parsed.success ? parsed.data : undefined;
153
+ if (err.status === 404) {
154
+ return textError(`Not applied: campaign \`${mdCell(campaignId)}\` is no longer in this network account. ` +
155
+ "Refresh with list_source_bids.");
156
+ }
157
+ if (err.status === 409) {
158
+ if (details?.code === "BID_CHANGED") {
159
+ return textError(`Not applied: ${err.message} Run list_source_bids and review the new current bid ` +
160
+ "before confirming again.");
161
+ }
162
+ if (details?.code === "CONFIRM_LARGE_INCREASE") {
163
+ return textError(`Refused: ${err.message} Nothing was sent to the network. Repeat with ` +
164
+ "`allow_large_increase: true` if the operator really wants this increase.");
165
+ }
166
+ }
167
+ if (err.status === 422) {
168
+ const change = details?.change;
169
+ const history = change?.status === "pending"
170
+ ? ` Change \`${mdCell(change.id)}\` remains pending in history. ` +
171
+ "Refresh with list_source_bids before retrying."
172
+ : change?.status === "failed"
173
+ ? " The attempt is recorded in the source's bid history as failed " +
174
+ `(change \`${mdCell(change.id)}\`).`
175
+ : "";
176
+ return textError(`Bid request refused: ${networkErrorText(err.message)}${history}`);
177
+ }
178
+ throw err;
179
+ }
180
+ return textResult([
181
+ `✅ Bid set at the network: ${campaignLine(current)} now bids ` +
182
+ `${formatBid(applied.campaign.bid)} (was ${formatBid(applied.change.previous_bid)}).`,
183
+ "",
184
+ applied.audit_finalized
185
+ ? `Recorded as change \`${mdCell(applied.change.id)}\`` +
186
+ `${applied.change.changed_by ? ` by ${mdCell(applied.change.changed_by)}` : ""}.`
187
+ : `⚠️ The network applied the bid, but change \`${mdCell(applied.change.id)}\` remains ` +
188
+ "pending in history. Refresh with list_source_bids before retrying.",
189
+ ].join("\n"));
190
+ }
191
+ catch (err) {
192
+ return errorResult(err);
193
+ }
194
+ }
195
+ //# sourceMappingURL=setSourceBid.js.map
@@ -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 UPDATE_TRAFFIC_SOURCE_DESCRIPTION: string;
6
+ export declare const updateTrafficSourceInputSchema: {
7
+ traffic_source_id: z.ZodString;
8
+ name: z.ZodOptional<z.ZodString>;
9
+ tracking_template: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
10
+ postback_template: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
11
+ api_token: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
12
+ status: z.ZodOptional<z.ZodEnum<["active", "archived"]>>;
13
+ confirm: z.ZodDefault<z.ZodBoolean>;
14
+ };
15
+ type UpdateTrafficSourceArgs = {
16
+ traffic_source_id: string;
17
+ name?: string;
18
+ tracking_template?: string | null;
19
+ postback_template?: string | null;
20
+ api_token?: string | null;
21
+ status?: (typeof TRAFFIC_SOURCE_STATUSES)[number];
22
+ confirm: boolean;
23
+ };
24
+ export declare function updateTrafficSource(client: AffsetClient, args: UpdateTrafficSourceArgs): Promise<CallToolResult>;
25
+ export {};