SEM Campaign Manager
README.md
# SEM Campaign Manager — Mock MCP Server
A local MCP server exposing 103 mocked Facebook Ads Manager–style campaigns
(Campaign → Ad Set → Ad, with 7-day insights), plus tools to flag
underperformers and run a propose → human-review → apply optimization loop.
Built to plug into a morning triage workflow: pull fresh performance data,
flag what's losing money, let the AI draft fixes, you approve, changes apply.
It also carries a `DEMO_MODE` toggle system for a conference talk — flip
one env var and restart to switch specific tools/errors/schemas between a
"before" (bad) and "after" (good) state, without editing code between
takes. See **Demo mode** below.
## Setup
```bash
pip install -r requirements.txt
python server.py # runs the MCP server over stdio + a dashboard on :5050
```
Point Claude Desktop / Claude Code at it, e.g. in `claude_desktop_config.json`:
```json
{
"mcpServers": {
"sem-campaign-manager": {
"command": "python",
"args": ["/absolute/path/to/files-mcp-project/server.py"]
}
}
}
```
Running `server.py` also starts a small Flask **review dashboard** at
`http://127.0.0.1:5050` in a background thread — same process, same
in-memory store as the MCP tools, so approving a change on the dashboard
is instantly visible to Claude and vice versa. `DASHBOARD=0` disables it;
`DASHBOARD_PORT=<port>` changes the port.
## Data model
Mirrors Meta Marketing API field names/objectives (`OUTCOME_SALES`,
`OUTCOME_LEADS`, etc.) so swapping the mock store for real API calls later
is mostly a matter of rewriting `store.py`'s data source — the tool
interface (`server.py`) shouldn't need to change.
- **Campaign**: id, name, status, objective, daily_budget, bid_strategy,
target_cpa, target_roas, insights_7d, `last_activity_date`, nested `adsets`
- **Ad Set**: targeting (age/gender/geo/interests), optimization_goal,
billing_event, daily_budget, insights_7d, `daily_trend_7d`, nested `ads`
- **Ad**: creative (headline, primary_text, CTA, `image_hash` filename), insights_7d
100 campaigns are generated with a seeded random mix (`mock_data.py`, RNG
isolated in its own `random.Random(SEED)` instance so it's reproducible
regardless of what else happens to be imported): ~30% strong, ~40%
average, ~30% engineered to be underperforming in a specific, diagnosable
way (declining trend, ad fatigue, weak CTR, or straight-up bad ROAS/CPA).
Each of ~20 brands' ~5 campaigns share one pseudo CRM client (bucketed by
brand), so per-client tools have a real portfolio to work with.
On top of those 100, `demo/demo_data.py` hand-seeds 3 campaigns for one
memorable client, **IKEA** (`client_id: client_ikea_demo`,
`monthly_budget_cap: $500`):
| Campaign | Status | Story |
|---|---|---|
| `CMP-4471` "Summer Sale" | ACTIVE | High CPA ($94.28 vs $40 target, 2.36x) + low CTR — the flagship demo campaign |
| `CMP-4472` | ACTIVE | Healthy / strong performer — no flags, "no action needed" |
| `CMP-4473` | PAUSED | Stale, `last_activity_date` months ago — dry-run-delete candidate |
## Tools
**Querying**
- `list_campaigns(status, objective, min_spend, max_spend, sort_by, order, limit, offset)`
- `get_campaign(campaign_id)` — full nested detail
- `get_account_summary()` — portfolio rollup, good first call each morning
- `get_campaign_daily_trend(campaign_id)` — 7-day daily breakdown per ad set
- `get_client_balance(campaign_id)` — the owning client's budget cap, current daily budget, remaining headroom *(demo: grounding toggle, see below)*
- `get_product_image_info(campaign_id)` — ad image filenames/dimensions; filenames vary from descriptive to opaque, deliberately
**Flagging**
- `get_underperforming_campaigns(roas_threshold, cpa_multiplier, ctr_threshold, frequency_threshold, status, limit)`
— returns campaigns with specific flags (`low_roas`, `high_cpa`, `low_ctr`,
`ad_fatigue`, `declining_trend`) and a plain-English reason for each
**Planning** (read-only — nothing here proposes or applies anything)
- `plan_campaign_changes(client_id)` — structured multi-step plan across every campaign one client owns
- `dry_run_delete(campaign_ids)` — preview what deleting campaign(s) would affect (name, status, last activity), without deleting
**Proposing changes** (draft only, nothing applied)
- `propose_budget_change(campaign_id, new_daily_budget, reason)` *(demo: tool-description / error-hint / reasoning toggles, see below)*
- `propose_creative_update(ad_id, headline, primary_text, cta, reason)`
- `propose_pause_resume(target_type, target_id, action, reason)`
**Human review** (only path that mutates data)
- `list_pending_changes(status)`
- `review_change(change_id, decision, note)` — `decision` is `approve` or `reject`
## Resources
- `demo://past-decisions` — reads `resources/past_decisions.md` live (not
cached) on every request. Seeded with one rejected proposal: "this
client runs a big sale every first weekend of the month, don't touch
budget then." Delete/restore the file (canonical copy at
`demo/seed_past_decisions.md`) to demo before/after without restarting
the server — see **Feedback loop** below.
## Demo mode
Isolate exactly one before/after concept per clip; every other concept
stays in its good state regardless of which mode you pick (see
`demo/demo_config.py`). `default` = every concept in its good state.
**Easiest way to switch takes: edit `DEMO_MODE.txt`** (plain text, project
root, just the mode name) and reconnect the MCP server in your client.
Claude Desktop's connector panel usually has a per-server reconnect
action that respawns the process without a full quit/reopen — much
faster than editing JSON. A full quit/reopen always works too if your
client doesn't expose that.
```
grounding_before
```
If `DEMO_MODE.txt` is empty, it falls back to the `DEMO_MODE` env var
(useful for one-off terminal runs without touching the file):
```bash
DEMO_MODE=grounding_before python server.py
```
Either way, the server has to actually restart to pick up the new value
— there's no live-reload. Check stderr on startup to confirm which mode
and source actually took effect: `[demo_config] DEMO_MODE='grounding_before' (from DEMO_MODE.txt)`.
| `DEMO_MODE` | What changes | Test prompt |
|---|---|---|
| `grounding_before` / `grounding_after` | `get_client_balance` unregistered vs. live, returning IKEA's real $500 cap / $380 budget / $120 headroom | "Should we increase the budget for IKEA's Summer Sale campaign?" |
| `tool_desc_before` / `tool_desc_after` | `propose_budget_change`'s description collapses to `"Changes the budget of a campaign."` vs. a full Purpose/Guidelines/Limitations/Parameters/Length/Examples description (params/behavior unchanged both ways) | "IKEA's Summer Sale campaign has a CPA problem, reduce its budget." |
| `error_before` / `error_after` | A budget-cap violation returns `{"error": "invalid input"}` vs. `{"error": "Budget exceeds monthly cap of $500 for IKEA. Propose a value under this limit, or check get_client_balance for the exact remaining amount."}` | "Bump IKEA's Summer Sale campaign budget up to $600." |
| `reasoning_before` / `reasoning_after` | `propose_budget_change`'s response shrinks to exactly `{status, campaign_id, new_budget}` (no `change_id` — the model can't even approve its own proposal without a separate lookup) vs. the full object with a `reasoning` field | "IKEA's Summer Sale campaign has a high CPA, cut the budget." |
### Data readiness (image filenames)
No toggle — both examples coexist on CMP-4471 so one `get_product_image_info("CMP-4471")` call shows the contrast: ad 1's file is `img_4471_final_v2.jpg` (opaque, versioned export — can't identify the product from the name alone), ad 2's is `red_sneaker_summer_2020_collection.jpg` (fully descriptive). Ask the model to identify the product in each ad and screenshot the difference in confidence/accuracy.
### Human-in-the-loop dashboard
`http://127.0.0.1:5050` starts with one pending proposal (a budget cut on
CMP-4471) and one already-approved change (a creative update on CMP-4472)
visible at the same time — no setup needed for the screenshot. Approve/
Reject buttons call `review_change` directly; the page auto-refreshes
every 5s, so a change proposed live through Claude appears without
reloading.
### Planning & dry run
```
plan_campaign_changes("client_ikea_demo") # 3-step plan: cut CMP-4471's budget,
# leave CMP-4472 alone, review CMP-4473 for archival
dry_run_delete(["CMP-4473"]) # name, status, last_activity_date -- nothing deleted
```
### Feedback loop
`resources/past_decisions.md` exists by default (full rejection reason
loaded). To demo the "before" state:
```bash
mv resources/past_decisions.md resources/past_decisions.md.bak
```
Ask the model to propose a change for CMP-4471 dated around the first
weekend of a month — without the resource, it has no reason to hold off.
Restore the file (`mv resources/past_decisions.md.bak resources/past_decisions.md`,
or re-copy from `demo/seed_past_decisions.md`) and ask again; the resource
reads live, so no restart is needed between the two takes.
## How this maps to the morning flow
1. Trigger the prompt → agent calls `get_account_summary()` then
`get_underperforming_campaigns()` to see what's actually losing money
and why.
2. Agent inspects specifics with `get_campaign()` /
`get_campaign_daily_trend()` / `get_client_balance()` /
`plan_campaign_changes()`, then drafts fixes with the `propose_*`
tools — nothing changes yet.
3. You review via `list_pending_changes()` or the dashboard at `:5050`.
4. You approve or reject with `review_change()` (or the dashboard
buttons, which call the same method). Only approved changes call
`_apply_change()` in `store.py`.
## Swapping in the real Meta Marketing API
Everything the AI calls goes through `store.py`. To go live:
- Replace `generate_dataset()` in `mock_data.py` with real calls to
`GET /act_{id}/campaigns` and `/insights` (cache/refresh per session).
- Replace `demo/demo_data.py`'s client bucketing with real CRM/client data.
- Point `_apply_change()` at the real update endpoints
(`POST /{campaign_id}` for budget, `POST /{ad_id}` for creative/status).
- Everything else — the tool surface, the flagging logic, the
propose/approve queue, the dashboard — stays the same.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues