Skip to content

feat(bidding): add update_ad_group_cpc_bid with a bid precondition guard - #12

Merged
mharnett merged 2 commits into
mainfrom
feat/update-ad-group-cpc-bid
Sep 4, 2026
Merged

mharnett merged 2 commits into
mainfrom
feat/update-ad-group-cpc-bid

Conversation

@mharnett

@mharnett mharnett commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The server had no bid write at any level, so max CPC changes were UI-only. This adds one, with the guard update_campaign_budget lacks.

Max CPC lives at ad group level in Bing (CpcBid.Amount) — there is no campaign-level max CPC — so changing "a campaign's max CPC" means one call per ad group.

Why two guards

A bid is the highest-blast-radius write this server exposes. A budget error is self-limiting (the daily cap bounds the loss); a bid error applies to every click in every auction with no ceiling.

Before — expected_current_bid is required, not defaulted. The live bid must match or the write is refused. A new bid is nearly always computed relative to the current one ("halve it", "back to $12") — exactly the reasoning that breaks silently when the current one has moved. update_campaign_budget will happily write from a stale read.

After — the value is read back and asserted. PartialErrors: [] does not mean a write landed on this API: a $400 budget write returned exactly that on 2026-08-24 and left the value at $100 for two days. Nothing reports success here without a re-read.

assertAdGroupBidPrecondition fails closed on a missing or unparseable live bid — treating "unknown" as "matches" would make the guard decoration.

Also rejects non-finite, non-positive, and >$500 bids. The ceiling is a misplaced-decimal catch, not a policy — the highest bid this account has ever carried is $24.

Enhanced CPC caveat, in the tool description

Under EnhancedCpc (or any auto strategy) CpcBid is a base bid Bing adjusts up and down, not a hard ceiling — and lowering it reduces ad rank, so it cuts volume as well as cost. The description says so, because "set max CPC to $10" reads like a cap and isn't one.

Tests

File Covers
src/mutationRequests.test.ts builder shape, fractional bids, non-positive / non-finite / over-ceiling rejection; guard match, drift, not-found, null-bid fail-closed, float tolerance
src/writeGate.test.ts classified as a write tool, hidden without BING_ADS_MCP_WRITE, exposed with it, expected_current_bid in required
src/tools.contract.test.ts tool name roster
RED:   9 failed | 7 passed    (both functions undefined)
GREEN: 16 passed
RED:   4 failed | 23 passed   (tool unregistered, not in WRITE_TOOLS)
GREEN: 156 passed

One unrelated pre-existing failure: the shared harness's npm audit check times out at 5s (network-dependent).

Verified live, not just green

Account 141522471, campaign bing_bofu_nonprofit_core. All three ad groups — Donation Software, Nonprofit Software, Nonprofit CRM — moved 24 → 10 with read-back confirmation. Then a deliberate replay carrying the now-stale expected_current_bid: 24 was refused:

Bid precondition failed for ad group "Donation Software" (1332610684483682):
expected 24, found 10. The ad group is not in the state this change was
computed against — re-read and retry.

So the guard is confirmed wired to the real call path with real arguments — not merely passing in isolation.

🤖 Generated with Claude Code

mharnett and others added 2 commits September 3, 2026 18:23
Max CPC lives at AD GROUP level in Bing (CpcBid.Amount) -- there is no
campaign-level max CPC -- so changing "a campaign's max CPC" means one call per
ad group. The server had no bid write at any level, so this was UI-only.

Two guards, because a bid is the highest-blast-radius write here: unlike a
budget, an over-large value has no daily cap to bound the loss.

- expected_current_bid is REQUIRED (not defaulted). The live bid must match it
  or the write is refused. A new bid is nearly always computed relative to the
  current one ("halve it", "back to $12"), which is exactly the reasoning that
  breaks silently when the current one has moved. update_campaign_budget has no
  equivalent and will happily write from a stale read.
- The result is read back and asserted before anything reports success.
  PartialErrors: [] does NOT mean a write landed on this API -- a $400 budget
  write returned exactly that on 2026-08-24 and left the value at $100 for two
  days. A mismatch throws and names both values.

assertAdGroupBidPrecondition fails closed on a missing/unparseable live bid;
treating "unknown" as "matches" would make the guard decoration.

Also rejects non-finite, non-positive, and >$500 bids. That ceiling is a
misplaced-decimal catch, not a policy: the highest bid this account has ever
carried is $24.

The tool description warns that under EnhancedCpc (or any auto strategy) this
is a BASE bid Bing adjusts, not a hard ceiling, and that lowering it cuts ad
rank and therefore volume, not just cost.

Tests added: src/mutationRequests.test.ts (builder + guard, 9 cases),
src/writeGate.test.ts (classification, visibility both ways, required arg),
src/tools.contract.test.ts (tool name roster).

RED:   9 failed | 7 passed  (both functions undefined)
GREEN: 16 passed
RED:   4 failed | 23 passed (tool unregistered, not in WRITE_TOOLS)
GREEN: 156 passed  (1 unrelated pre-existing failure: the shared harness's
       npm-audit check times out at 5s, network-dependent)

Verified live against account 141522471, campaign bing_bofu_nonprofit_core:
all 3 ad groups moved 24 -> 10 with read-back confirmation, and a deliberate
replay with the now-stale expected_current_bid: 24 was refused by the guard --
so the guard is confirmed wired to the real call path with real arguments,
not just green in isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…out fix

0.1.3 backports the dependency-health fix: the npm-audit test gave its
execSync a 30s budget but ran the it() under vitest's 5s default, so a
slow-but-healthy audit was killed by the runner. That failed CI here on every
ubuntu leg (10.7s) while all three windows legs passed.

Stays on the 0.1.x line deliberately. 0.2.x adds a stdout-purity suite whose
timeout has the same defect -- a 10s ceiling against 5s of hardcoded internal
waits -- and it fails against this server, which does a network auth refresh
before connecting its transport. Verified by overlaying the 0.2.x build here:
the audit test goes green and stdout-purity goes red, isolated, at 10.32s.
Trading one red for another.

Local: 157 passed (was 156 passed, 1 failed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mharnett
mharnett merged commit 26013cb into main Sep 4, 2026
6 checks passed
@mharnett
mharnett deleted the feat/update-ad-group-cpc-bid branch September 4, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant