Skip to main content
Glama
frkonrad112211

SEM Campaign Manager

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

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:

{
  "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.

Related MCP server: facebook-business-mcp

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):

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:

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Connects AI assistants to Facebook's Ads API to enable natural language queries for campaign data, insights, and performance metrics. It allows users to manage ad accounts and retrieve detailed analytics like impressions, clicks, and spend through MCP-compatible interfaces.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Unofficial MCP server implementation for Facebook Business API. Enables LLMs to manage ad campaigns and ads via natural language.
    2
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables management of OpenAI Ads campaigns, ad groups, ads, and insights through MCP, with approval-gated live mutations for safe write operations.
    33
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables reading and mutating Google Ads campaigns via MCP tools, and includes structured marketer workflows for search term mining, budget optimization, and weekly reviews.
    83
    5
    MIT