@affset/mcp 0.3.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.
package/README.md CHANGED
@@ -39,6 +39,8 @@ unless you set `AFFSET_READ_ONLY=true`.
39
39
  | `list_traffic_sources` | List traffic sources — the networks bought from, each with the tracking/postback templates its linked zones use. API token shown only as set/none. |
40
40
  | `create_traffic_source` | Create a traffic source, optionally from a network preset (`exoclick`, `trafficstars`, `propellerads`, `adsterra`, `richads`) that copies verified templates into an editable row. **Dry-run by default**; `confirm: true` to apply. |
41
41
  | `update_traffic_source` | Partial update (name, templates, api_token, status). Linked zones pick the new tracking template up immediately. **Dry-run by default**; `confirm: true` to apply. |
42
+ | `list_source_bids` | The network campaigns of one traffic source with their **current bids**, read live from the network account (ExoClick, TrafficStars, RichAds — needs the source's API token): status, pricing model, bid in USD, last change that entered Affset's mutation stage. Read-only. |
43
+ | `set_source_bid` | Set one network campaign's bid in USD using its existing pricing model (for RichAds: CPM for pops, CPC for push/display). The dry run returns `expected_current_bid`; pass that value back with `confirm: true` to bind the write to the bid reviewed, so a concurrent change is refused rather than overwritten. Records the attempt in the source's bid history and reports applied only when the network echoes the new value. Raising a bid above 5× needs `allow_large_increase: true`. **Dry-run by default**. |
42
44
  | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. A zone linked to a traffic source renders that source's tracking template; otherwise prefilled sub convention + optional `cost` macro. Warns when no active campaigns are visible. |
43
45
  | `get_tracking_link` | The `/track/click` link for an existing campaign + zone — straight to one active campaign, with no rotation or targeting checks. Renders a linked source's template like `get_zone_url`. Re-derives what `create_campaign` echoed on create. |
44
46
  | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
@@ -0,0 +1,21 @@
1
+ import type { SourceBidCampaign, SourceBidChange } from "../types.js";
2
+ /** The precision accepted by the source-bid API. */
3
+ export declare const BID_DECIMALS = 6;
4
+ /** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
5
+ export declare const LARGE_INCREASE_FACTOR = 5;
6
+ /** Network campaign names come from the network account — third-party text in model context. */
7
+ export declare const NETWORK_NAME_MAX = 120;
8
+ export declare function pricingModelLabel(model: string): string;
9
+ /** Preserve every decimal place that can be sent to the network. */
10
+ export declare function formatBid(bid: number | null | undefined): string;
11
+ /** Network-supplied error text that can land in the model's context. */
12
+ export declare function networkErrorText(message: string): string;
13
+ /** Match the API's optimistic-concurrency comparison, including unknown bids. */
14
+ export declare function bidsMatch(left: number | null, right: number | null): boolean;
15
+ /** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
16
+ export declare function describeBidChange(previous: number | null | undefined, next: number): string;
17
+ export declare function isLargeIncrease(previous: number | null | undefined, next: number): boolean;
18
+ /** One-line rendering of a change row for tables and confirmations. */
19
+ export declare function describeChangeRow(change: SourceBidChange): string;
20
+ /** Markdown table of a source's network campaigns with their live bids. */
21
+ export declare function formatBidsTable(campaigns: SourceBidCampaign[]): string;
@@ -0,0 +1,84 @@
1
+ import { capUntrusted, mdCell } from "./format.js";
2
+ /** The precision accepted by the source-bid API. */
3
+ export const BID_DECIMALS = 6;
4
+ /** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
5
+ export const LARGE_INCREASE_FACTOR = 5;
6
+ /** Network campaign names come from the network account — third-party text in model context. */
7
+ export const NETWORK_NAME_MAX = 120;
8
+ const NETWORK_TOKEN_MAX = 40;
9
+ const MODEL_LABEL = {
10
+ cpc: "CPC",
11
+ cpm: "CPM",
12
+ cpa: "CPA",
13
+ cpv: "CPV",
14
+ smart_cpm: "Smart CPM",
15
+ smart_cpc: "Smart CPC",
16
+ smart_bid: "Smart bid",
17
+ };
18
+ export function pricingModelLabel(model) {
19
+ if (!model)
20
+ return "—";
21
+ return MODEL_LABEL[model] ?? mdCell(capUntrusted(model, NETWORK_TOKEN_MAX));
22
+ }
23
+ /** Preserve every decimal place that can be sent to the network. */
24
+ export function formatBid(bid) {
25
+ if (bid == null || !Number.isFinite(bid))
26
+ return "—";
27
+ return `$${bid.toLocaleString("en-US", {
28
+ minimumFractionDigits: 2,
29
+ maximumFractionDigits: BID_DECIMALS,
30
+ })}`;
31
+ }
32
+ /** Network-supplied error text that can land in the model's context. */
33
+ export function networkErrorText(message) {
34
+ return mdCell(capUntrusted(message));
35
+ }
36
+ function roundBid(bid) {
37
+ const scale = 10 ** BID_DECIMALS;
38
+ return Math.round(bid * scale) / scale;
39
+ }
40
+ /** Match the API's optimistic-concurrency comparison, including unknown bids. */
41
+ export function bidsMatch(left, right) {
42
+ return left === null || right === null ? left === right : roundBid(left) === roundBid(right);
43
+ }
44
+ /** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
45
+ export function describeBidChange(previous, next) {
46
+ return `${formatBid(previous)} → ${formatBid(next)}`;
47
+ }
48
+ export function isLargeIncrease(previous, next) {
49
+ return previous != null && previous > 0 && next > roundBid(previous * LARGE_INCREASE_FACTOR);
50
+ }
51
+ /** One-line rendering of a change row for tables and confirmations. */
52
+ export function describeChangeRow(change) {
53
+ const when = new Date(change.created_at).toISOString().replace("T", " ").slice(0, 16);
54
+ const who = change.changed_by ? ` by ${mdCell(change.changed_by)}` : "";
55
+ const outcome = change.status === "applied"
56
+ ? "applied"
57
+ : change.status === "pending"
58
+ ? `outcome pending${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`
59
+ : `failed${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`;
60
+ return `${when} UTC · ${describeBidChange(change.previous_bid, change.bid)}${who} · ${outcome}`;
61
+ }
62
+ /** Markdown table of a source's network campaigns with their live bids. */
63
+ export function formatBidsTable(campaigns) {
64
+ if (campaigns.length === 0)
65
+ return "_The network account has no campaigns._";
66
+ const lines = [
67
+ "| Campaign | Network id | Status | Model | Bid | Last change via affset |",
68
+ "|---|---|---|---|--:|---|",
69
+ ];
70
+ for (const campaign of campaigns) {
71
+ const name = campaign.name ? mdCell(capUntrusted(campaign.name, NETWORK_NAME_MAX)) : "—";
72
+ const status = campaign.status === "other" && campaign.status_label
73
+ ? mdCell(capUntrusted(campaign.status_label, NETWORK_TOKEN_MAX))
74
+ : campaign.status;
75
+ const extra = Object.entries(campaign.extra)
76
+ .map(([key, value]) => `${mdCell(capUntrusted(key, NETWORK_TOKEN_MAX))} ${formatBid(value)}`)
77
+ .join(", ");
78
+ const bid = campaign.bid === null ? "—" : `${formatBid(campaign.bid)}${extra ? ` (${extra})` : ""}`;
79
+ const last = campaign.last_change ? describeChangeRow(campaign.last_change) : "—";
80
+ lines.push(`| ${name} | \`${mdCell(campaign.network_campaign_id)}\` | ${status} | ${pricingModelLabel(campaign.pricing_model)} | ${bid} | ${last} |`);
81
+ }
82
+ return lines.join("\n");
83
+ }
84
+ //# sourceMappingURL=bids.js.map
@@ -16,6 +16,8 @@ import { updateZone, updateZoneInputSchema, UPDATE_ZONE_DESCRIPTION } from "./to
16
16
  import { listTrafficSources, listTrafficSourcesInputSchema, LIST_TRAFFIC_SOURCES_DESCRIPTION, } from "./tools/listTrafficSources.js";
17
17
  import { createTrafficSource, createTrafficSourceInputSchema, CREATE_TRAFFIC_SOURCE_DESCRIPTION, } from "./tools/createTrafficSource.js";
18
18
  import { updateTrafficSource, updateTrafficSourceInputSchema, UPDATE_TRAFFIC_SOURCE_DESCRIPTION, } from "./tools/updateTrafficSource.js";
19
+ import { listSourceBids, listSourceBidsInputSchema, LIST_SOURCE_BIDS_DESCRIPTION, } from "./tools/listSourceBids.js";
20
+ import { setSourceBid, setSourceBidInputSchema, SET_SOURCE_BID_DESCRIPTION, } from "./tools/setSourceBid.js";
19
21
  import { updateCampaign, updateCampaignInputSchema, UPDATE_CAMPAIGN_DESCRIPTION, } from "./tools/updateCampaign.js";
20
22
  import { setCampaignStatus, setCampaignStatusInputSchema, SET_CAMPAIGN_STATUS_DESCRIPTION, } from "./tools/setCampaignStatus.js";
21
23
  import { listPayoutRules, listPayoutRulesInputSchema, LIST_PAYOUT_RULES_DESCRIPTION, } from "./tools/listPayoutRules.js";
@@ -282,6 +284,28 @@ export function registerAffsetTools(toolServer, config, options = {}) {
282
284
  openWorldHint: true,
283
285
  },
284
286
  }, (args) => updateTrafficSource(client, args));
287
+ registerTool("list_source_bids", {
288
+ title: "List a traffic source's network campaign bids",
289
+ description: LIST_SOURCE_BIDS_DESCRIPTION,
290
+ inputSchema: listSourceBidsInputSchema,
291
+ annotations: {
292
+ readOnlyHint: true,
293
+ destructiveHint: false,
294
+ idempotentHint: true,
295
+ openWorldHint: true,
296
+ },
297
+ }, (args) => listSourceBids(client, args));
298
+ registerTool("set_source_bid", {
299
+ title: "Set a network campaign's bid",
300
+ description: SET_SOURCE_BID_DESCRIPTION,
301
+ inputSchema: setSourceBidInputSchema,
302
+ annotations: {
303
+ readOnlyHint: false,
304
+ destructiveHint: true,
305
+ idempotentHint: true,
306
+ openWorldHint: true,
307
+ },
308
+ }, (args) => setSourceBid(client, args));
285
309
  registerTool("cut_zones", {
286
310
  title: "Cut underperforming zones",
287
311
  description: CUT_ZONES_DESCRIPTION,
@@ -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,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
package/dist/types.d.ts CHANGED
@@ -177,6 +177,58 @@ export interface TrafficSourcePreset {
177
177
  export interface TrafficSourcePresetsResponse {
178
178
  presets: TrafficSourcePreset[];
179
179
  }
180
+ /**
181
+ * One write that entered Affset's mutation stage
182
+ * (GET /api/traffic-sources/{id}/bid-changes and `last_change` on the bids list).
183
+ * `changed_by` is the caller's email.
184
+ */
185
+ export interface SourceBidChange {
186
+ id: string;
187
+ source_id: string;
188
+ network_campaign_id: string;
189
+ previous_bid: number | null;
190
+ bid: number;
191
+ currency: string;
192
+ pricing_model: string;
193
+ status: "pending" | "applied" | "failed";
194
+ error: string | null;
195
+ changed_by: string;
196
+ created_at: number;
197
+ }
198
+ /**
199
+ * One network campaign from GET /api/traffic-sources/{id}/bids — read live from
200
+ * the network account. `name` and `status_label` are the network's own text.
201
+ */
202
+ export interface SourceBidCampaign {
203
+ network_campaign_id: string;
204
+ name: string;
205
+ status: "active" | "paused" | "other";
206
+ status_label: string;
207
+ bid: number | null;
208
+ currency: string;
209
+ pricing_model: string;
210
+ extra: Record<string, number>;
211
+ last_change: SourceBidChange | null;
212
+ }
213
+ export interface SourceBidsResponse {
214
+ campaigns: SourceBidCampaign[];
215
+ fetched_at: number;
216
+ }
217
+ /** Response of POST /api/traffic-sources/{id}/bids. */
218
+ export interface SetSourceBidResponse {
219
+ change: SourceBidChange;
220
+ campaign: {
221
+ network_campaign_id: string;
222
+ name: string;
223
+ status: string;
224
+ bid: number | null;
225
+ previous_bid: number | null;
226
+ currency: string;
227
+ pricing_model: string;
228
+ };
229
+ audit_finalized: boolean;
230
+ warning?: string;
231
+ }
180
232
  /** Subset of GET /api/tenant this server reads. */
181
233
  export interface TenantSettingsResponse {
182
234
  company?: string;
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * package.json here would break bundlers; a sync test guards the duplicate
5
5
  * (see registerTools.test.ts).
6
6
  */
7
- export declare const VERSION = "0.3.0";
7
+ export declare const VERSION = "0.4.0";
package/dist/version.js CHANGED
@@ -4,5 +4,5 @@
4
4
  * package.json here would break bundlers; a sync test guards the duplicate
5
5
  * (see registerTools.test.ts).
6
6
  */
7
- export const VERSION = "0.3.0";
7
+ export const VERSION = "0.4.0";
8
8
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affset/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.affset/mcp",
5
5
  "description": "MCP server for the affset ad platform — stats, campaigns, zones, payouts, targeting, sub labels, and team from your chat client.",
6
6
  "type": "module",