pubweb-ads-mcp
v0.4.0
Published
MCP server exposing the pubweb external API to Claude and other MCP clients: a semantic-search knowledge base of scraped competitor ads (media + funnels), your AdX ad-serving telemetry, and cross-platform P&L insights (GAM revenue vs Meta ad spend).
Readme
pubweb-ads-mcp
An MCP server that gives Claude three things from your pubweb account:
- Competitor ads — a knowledge base of scraped ads with semantic search, ad copy, images/videos and the landing→destination funnels, so Claude can reason over them like a RAG.
- Your AdX telemetry — the ad-serving funnel (requests → fills → viewables → clicks) for the sites you own.
- Your cross-platform P&L — GAM revenue joined to Meta ad spend, per placement and per ad set, with ROI/ROAS, data-quality flags, and the methodology catalogue that explains how the numbers are built.
It's a thin, secure wrapper over the pubweb external API (/api/v1): one bearer API key,
every call scoped server-side to your account.
Setup (2 steps)
1. Create an API key — in pubweb → Settings → API Keys → "Create key for Claude/MCP". Pick the abilities you need (see the table below). Copy the key (shown once).
| Ability | Unlocks |
|---------|---------|
| v1:ads:read | ads_browse, ad_get, ad_media, ad_funnel, targets_list |
| v1:ads:search | ads_search (semantic/vector search) |
| v1:ads:write | add_target (start tracking a new competitor domain) |
| v1:adx:read | adx_kpis, adx_timeseries, adx_fill_report |
| v1:insights:read | insights_placements, insights_adsets, insights_learnings |
whoami needs no ability beyond a valid key. A tool called without its ability returns
Error: API 403 … — mint a new key with the missing ability.
2. Add the server to your MCP client.
Claude Desktop (claude_desktop_config.json) or Claude Code (.mcp.json):
{
"mcpServers": {
"pubweb-ads": {
"command": "npx",
"args": ["-y", "pubweb-ads-mcp"],
"env": { "PUBWEB_API_KEY": "PASTE_YOUR_KEY_HERE" }
}
}
}That's it — restart the client and ask Claude things like "search the ad knowledge base for job-offer ads", "what's my AdX fill rate this week" or "which Meta placement is burning money with no revenue?".
Self-hosted / staging? add
"PUBWEB_API_URL": "https://your-domain"toenv(defaulthttps://app.pubweb.ai).
Run without npx (local checkout)
cd mcp/ads-mcp && npm install
PUBWEB_API_KEY=... node src/index.js # stdio server
PUBWEB_API_KEY=dummy npm run smoke # lists the tools (sanity check, no API needed)
PUBWEB_API_KEY=<real> node test/live.mjs # calls real tools against /api/v1…and point the MCP client's command/args at node /abs/path/mcp/ads-mcp/src/index.js.
Tools at a glance
| Tool | What it returns | Ability |
|------|-----------------|---------|
| ads_search | Semantic (vector) ad search — multilingual, ranked | v1:ads:search |
| ads_browse | Keyword/filter ad browse, paginated | v1:ads:read |
| ad_get | One ad — full copy, media list, funnel summary | v1:ads:read |
| ad_media | Presigned image/video URLs (30-min TTL) | v1:ads:read |
| ad_funnel | The ad's landing→destination funnel | v1:ads:read |
| targets_list | Competitor domains you track, with ad counts | v1:ads:read |
| add_target | Start tracking a competitor domain | v1:ads:write |
| whoami | Verify the key + see its abilities | — |
| adx_kpis | AdX funnel KPIs for your sites | v1:adx:read |
| adx_timeseries | AdX funnel per hour/day/week | v1:adx:read |
| adx_fill_report | AdX funnel by block/country/price bucket/size/device | v1:adx:read |
| insights_placements | Revenue vs spend P&L per Meta placement | v1:insights:read |
| insights_adsets | Revenue vs spend P&L per Meta ad set | v1:insights:read |
| insights_learnings | The A..I methodology catalogue | v1:insights:read |
Tool reference
ads_search
Semantic (vector) search over the ad knowledge base. Multilingual — "emprego", "job" and "empleo" all match the same concept. Use it for "find ads about X".
Endpoint GET /api/v1/ads/search · Ability v1:ads:search
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| query | string | yes | — | Natural-language query (sent as ?q=) |
| network | string | no | all | Ad network, e.g. meta |
| funnel_type | string | no | all | Funnel type filter |
| domain | string | no | all | Restrict to one tracked domain, e.g. como-funciona.com |
| target_id | string | no | all | Restrict to one target uuid (from targets_list) |
Returns { data: [ad…], meta: { semantic, total } } — each ad has copy, advertiser,
thumbnail URL and a funnel summary.
Example ads_search({ query: "oferta de emprego", network: "meta" })
When to use this instead of ads_browse — whenever relevance matters. ads_browse is for
exact filters, date ranges and paging.
Gotchas — meta.semantic: false means the embedding backend was unavailable and the API
fell back to keyword matching; ranking is then much weaker.
ads_browse
Keyword + filter browse of ads (no embeddings), paginated.
Endpoint GET /api/v1/ads · Ability v1:ads:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| q | string | no | — | Keyword over advertiser/headline/link/funnel |
| network | string | no | all | Ad network |
| funnel_type | string | no | all | Funnel type |
| format | string | no | all | display_format, e.g. image | video |
| domain | string | no | all | One tracked domain |
| target_id | string | no | all | One target uuid |
| from | string | no | — | ISO date — ads last seen on/after |
| to | string | no | — | ISO date — ads last seen on/before |
| per_page | int 1-50 | no | API default | Page size |
| page | int ≥1 | no | 1 | Page number |
Returns a paginated envelope { data: [...], meta: { current_page, last_page, total }, links }.
Example ads_browse({ domain: "como-funciona.com", format: "video", per_page: 50, page: 2 })
When to use this instead of ads_search — exact filtering, date windows, or walking the
full inventory.
Gotchas — page size caps at 50; to reach later results you must pass page (added in
v0.4.0 — earlier versions could only ever see page 1). Always check meta.last_page.
ad_get
Fetch one ad by id: full copy (headline/body/cta), advertiser, media list and funnel summary.
Endpoint GET /api/v1/ads/{id} · Ability v1:ads:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| id | string (uuid) | yes | — | Ad id from a search/browse result |
Returns { data: { id, advertiser_name, headline, body, cta, media: [...], funnel: {...} } }.
Example ad_get({ id: "0198f0…" })
When to use this instead of ads_search — you already have the id and want the complete
record rather than a search-result summary.
Gotchas — the media entries here are metadata; use ad_media for URLs you can actually
open.
ad_media
Presigned image/video URLs for an ad, so you can view or download the creatives.
Endpoint GET /api/v1/ads/{id}/media · Ability v1:ads:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| id | string (uuid) | yes | — | Ad id |
Returns { data: [{ type, url, width, height, … }] }.
Example ad_media({ id: "0198f0…" })
Gotchas — the URLs are presigned with a 30-minute TTL. Fetch them right before use; a URL pasted into a document will 403 later. Re-call the tool to get fresh ones.
ad_funnel
The mapped landing → destination funnel for an ad: gateway, the intermediate steps with their URLs, and the final destination.
Endpoint GET /api/v1/ads/{id}/funnel · Ability v1:ads:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| id | string (uuid) | yes | — | Ad id |
Returns { data: { funnel_type, gateway, steps: [{ url, … }], destination } }.
Example ad_funnel({ id: "0198f0…" })
When to use this instead of ad_get — when you specifically want the click path (to copy a
competitor's funnel structure); ad_get only carries a summary.
Gotchas — funnels are mapped by the scraper and can be partial for ads whose landing page blocked the crawl.
targets_list
List the competitor targets (advertiser domains) tracked in your account, with ad counts.
Endpoint GET /api/v1/targets · Ability v1:ads:read
| Param | Type | Required | Default | Meaning | |-------|------|----------|---------|---------| | — | — | — | — | No parameters |
Returns { data: [{ id, domain, ads_count, … }] }.
Example targets_list({})
When to use this — before ads_search/ads_browse, to learn the target_id / domain
values you can filter by.
add_target
Start tracking a competitor domain — adds it to your monitored targets and kicks off the first scrape. Same as adding a domain in the web panel.
Endpoint POST /api/v1/targets · Ability v1:ads:write
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| domain | string | yes | — | Domain to track, e.g. como-funciona.com |
Returns { data: { id, domain, … } } (201 on create).
Example add_target({ domain: "https://www.como-funciona.com/path" }) — scheme, www. and
path are stripped server-side.
Gotchas — this is the only write tool; it needs v1:ads:write, which most keys don't
have. The ads library is global by domain: if another tenant already tracks that domain you
get the existing record back rather than a duplicate, and the first scrape may already be done.
Results are not instant — the scrape runs on a queue.
whoami
Verify the API key works and show its abilities.
Endpoint GET /api/v1/me · Ability — (any valid key)
| Param | Type | Required | Default | Meaning | |-------|------|----------|---------|---------| | — | — | — | — | No parameters |
Returns { data: { name, email, abilities: [...] } }.
Example whoami({})
When to use this — first, whenever any other tool returns a 401/403. It tells you whether the key is invalid (401) or simply missing an ability (403).
adx_kpis
AdX ad-serving funnel KPIs over a window for your sites: requests, fills, unfills,
viewables, clicks, refreshes, plus derived fill_rate, viewability, ctr.
Endpoint GET /api/v1/adx/kpis · Ability v1:adx:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| site | string | no | all your sites | One of your site ids |
| from | string | no | 7 days ago | ISO date — window start |
| to | string | no | now | ISO date — window end |
Returns { data: { requests, fills, unfilled, viewables, clicks, refreshes, fill_rate, viewability, ctr }, meta: { from, to, … } }.
Example adx_kpis({ from: "2026-07-01", to: "2026-07-24" })
When to use this instead of insights_placements — this is funnel health only: no
revenue, no cost, no ROI. For money, use the insights tools.
Gotchas — telemetry lives in ClickHouse; if it's unavailable the endpoint returns zeros
rather than an error, so an all-zero response can mean "no traffic" or "backend down". Cross-
check with adx_timeseries over a longer window before concluding traffic stopped.
adx_timeseries
The same AdX counters as adx_kpis, bucketed over time.
Endpoint GET /api/v1/adx/timeseries · Ability v1:adx:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| site | string | no | all your sites | One of your site ids |
| from | string | no | 7 days ago | ISO date — window start |
| to | string | no | now | ISO date — window end |
| granularity | hour | day | week | no | day | Bucket size |
Returns { data: [{ bucket, requests, fills, … }], meta: {...} }.
Example adx_timeseries({ granularity: "hour", from: "2026-07-23", to: "2026-07-24" })
When to use this instead of adx_kpis — to spot when something broke (a fill-rate cliff
at a specific hour) rather than the window average.
Gotchas — hour granularity over a long window returns a lot of buckets; keep the window
short. Buckets are emitted in the site's reporting timezone, not yours.
adx_fill_report
AdX funnel broken down by block (code), country (cc), price_rule (floor-ladder bucket),
brand-safety, creative size and device.
Endpoint GET /api/v1/adx/fill-report · Ability v1:adx:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| site | string | no | all your sites | One of your site ids |
| from | string | no | 7 days ago | ISO date — window start |
| to | string | no | now | ISO date — window end |
Returns { data: { by_block: [...], by_country: [...], by_price_rule: [...], by_size: [...], by_device: [...] }, meta: {...} }.
Example adx_fill_report({ site: "0198f0…", from: "2026-07-18" })
When to use this — diagnosing which block/country/floor bucket is dragging the fill rate
down after adx_kpis shows the aggregate is bad.
Gotchas — each breakdown sums to the same totals; don't add rows across breakdowns.
price_rule = "auto" means the fill cleared with no manual floor (AdX dynamic allocation), not
a floor of zero.
insights_placements
Profit & loss per Meta placement (Facebook_Mobile_Reels, Instagram_Feed,
Facebook_Marketplace, an, …): GAM revenue (gross in the network currency, publisher_share,
net USD) joined to Meta ad spend (USD, after flex_rate), with impressions/clicks on both
sides, plus ROI, ROAS and eCPM.
Endpoint GET /api/v1/insights/placements · Ability v1:insights:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| from | string Y-m-d | no | to − 29d | Window start (max span 92 days, clamped) |
| to | string Y-m-d | no | today | Window end |
| site_id | string (uuid) | no | all | Restrict to one of your sites |
| integration_id | string (uuid) | no | all | One of your integrations — a GAM one narrows the revenue leg, a Facebook one narrows the spend leg |
| adset_id | string | no | all | A single Meta fb_adset_id you own |
| currency | string (ISO-4217) | no | — | Extra display currency; adds a converted block on top of USD |
| limit | int 1-500 | no | 100 | Max rows returned |
| order | spend | revenue | profit | roi | no | spend | Sort key, descending |
Returns { data: [row…], meta: {…} }. Each row:
{
"placement": "Facebook_Mobile_Reels",
"revenue": { "gross_native": {"AED": 412.556}, "publisher_share": 0.68,
"gross_usd": 112.34, "net_usd": 76.39,
"impressions": 10432, "clicks": 23, "ecpm_usd": 7.32, "fx_complete": true },
"spend": { "native": {"BRL": 189.44}, "flex_rate": 1.0, "usd": 37.32,
"impressions": 88231, "clicks": 1204, "cpm_usd": 0.42, "cpc_usd": 0.031,
"reach": null, "reach_reason": "non_additive_at_placement_grain" },
"roi": { "profit_usd": 39.07, "roi_pct": 104.7, "roas": 2.047, "margin_pct": 51.15 },
"roi_suppressed_reason": null,
"quality": { "has_spend": true, "has_revenue": true, "zero_revenue_with_spend": false }
}Example
insights_placements({ from: "2026-06-25", to: "2026-07-24", order: "spend", limit: 20 })When to use this instead of insights_adsets — to decide which placement to cut across
all campaigns. Use insights_adsets to decide which ad set to cut.
Gotchas — read the caveats before trusting any number:
meta.totals.revenue_total_is_utm_sliceistrue: the revenue total here is a UTM slice, not the authoritative window total. Compare againstmeta.reference.net_usd(fromgam_reports_adunit) andmeta.reference.utm_coverage_pct.- Spend at this grain reads only rows where
placement <> ''. The table also stores an aggregate row per (adset, day) withplacement = ''; summing both would double every dollar.meta.quality.placement_spend_reconcilestells you whether the two grains agree — iffalse, the placement sync is corrupt and the split is untrustworthy (the aggregate total is still right). spend.reachandspend.frequencyare alwaysnull— reach is not additive across placements (one person on two devices counts twice). Never ask for a summed reach.- Revenue is gross in the network's native currency;
publisher_shareis a separate field andnet_usdis what you actually keep. Never comparegross_usdagainst spend. - FX uses each row's own date. If
meta.partialistrue, some(currency, day)rates are missing (meta.fx_missing_days), and affected rows carryroi: null+roi_suppressed_reason: "fx_incomplete"rather than a wrong ROI. - GAM days close in the network report timezone, Meta days close in the ad-account
timezone — same-named days are slightly misaligned at window edges (
meta.timezones). - Placements with spend and zero revenue are returned on purpose
(
quality.zero_revenue_with_spend: true) — they are the most actionable rows. - No integrations on the key owner's account →
data: []with a 200, not an error. - A
site_id/integration_id/adset_idyou don't own returns 422, byte-identical to a nonexistent one.
insights_adsets
The same revenue-vs-spend P&L keyed by Meta ad set. Each row additionally carries the adset
(name, status, optimization goal), its campaign, the ad account (currency + timezone +
flex_rate) and the linked pubweb site.
Endpoint GET /api/v1/insights/adsets · Ability v1:insights:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| from | string Y-m-d | no | to − 29d | Window start (max span 92 days) |
| to | string Y-m-d | no | today | Window end |
| site_id | string (uuid) | no | all | One of your sites |
| integration_id | string (uuid) | no | all | One of your integrations |
| adset_id | string | no | all | A single fb_adset_id you own |
| currency | string (ISO-4217) | no | — | Extra display currency |
| limit | int 1-500 | no | 100 | Max rows |
| order | spend | revenue | profit | roi | no | spend | Sort key, descending |
Returns the same envelope as insights_placements with meta.grain: "adset", each row
prefixed by adset / campaign / ad_account / site blocks, and spend additionally
carrying conversions and conversion_values_native.
Example
insights_adsets({ site_id: "0198f0…", order: "roi", limit: 50 })When to use this instead of insights_placements — placement tells you where the money
leaks across the whole account; this tells you which specific ad set to pause.
Gotchas — everything from insights_placements, plus:
- Spend here reads only the aggregate rows (
placement = ''). That's the right total; don't add it to the placement-grain numbers. - Revenue is joined on
utm_content=<fb_adset_id>only.utm_termcarries the same adset id in GAM — summing both double-counts, which is why the API deliberately ignores it. reachisnullandreach_daily_maxis offered instead: reach isn't additive across days either.- Row-level
quality.placement_spend_reconcilesflags an adset whose placement breakdown doesn't sum to its aggregate spend — trust that row's total, distrust its placement split. - There is no adset→site link in the data model: a site is inherited from the adset's
campaign link, so
sitecan benulleven for a live adset.
insights_learnings
The methodology catalogue (A..I): the nine rules that separate a correct
revenue-vs-spend number from a plausible-but-wrong one. Each entry has the rule, why it
matters, the real production incident that proved it, and a SQL check that detects the
violation.
Endpoint GET /api/v1/insights/learnings · Ability v1:insights:read
| Param | Type | Required | Default | Meaning |
|-------|------|----------|---------|---------|
| id | string | no | all nine | A single learning id: A…I |
Returns { data: [{ id, slug, title, rule, why, incident, applies_to, enforced_by, detect, severity }], meta: { version, count, ids } }.
| id | In one line |
|----|-------------|
| A | facebook_insights_daily holds two grains, discriminated by placement — never mix or sum both |
| B | reach / frequency are not additive — never sum them |
| C | Placement rows must reconcile to their aggregate row within 0.5% |
| D | gam_reports_utm is dimension-sliced — never sum it for a total |
| E | Revenue is gross + native currency; expose publisher_share and net separately |
| F | Convert FX at the row's own date, from stored daily rates |
| G | GAM days and Facebook days close in different timezones |
| H | Never inner-join spend to revenue — zero-revenue spend rows are the point |
| I | The {{placement}} macro vocabulary ≠ the Meta API breakdown vocabulary |
Example insights_learnings({ id: "C" })
When to use this — before interpreting insights_placements / insights_adsets, and
always before writing your own SQL against Meta spend or GAM revenue.
Gotchas — pure static content, no DB, but it still requires v1:insights:read. An unknown
id returns 422, not an empty list.
Reading an insights response
- Check
meta.partialandmeta.warningsfirst.partial: truemeans some FX rates were missing and USD figures cover only the rated days — the fix is afx:backfillrun on the server, not a retry. meta.totals.revenue_total_is_utm_slice: true— the revenue total is a UTM slice. The authoritative window revenue ismeta.reference.net_usd(fromgam_reports_adunit);meta.reference.utm_coverage_pcttells you how much of it the UTM slice captured.meta.quality—placement_spend_reconciles: falsemeans the placement split is corrupt for at least one (adset, day);worst_mismatchesnames them. The aggregate spend total is still correct.spend.reachis intentionallynull. Not missing data — non-additive by nature.- A row with
roi: nullcarriesroi_suppressed_reason(e.g."fx_incomplete"). A suppressed ROI is deliberate: a number computed over mismatched day coverage would be plausible and wrong. - Rows with spend and zero revenue are real findings, not join failures — the API uses a union, never an inner join.
Error messages you will see
Every failure is surfaced as a normal tool error, in one of two shapes:
Error: API <status> on <path>: <first 400 chars of the body>
Error: network error calling <path>: <reason>| Status | Cause | Fix |
|--------|-------|-----|
| 401 | Key missing, revoked or malformed | Check PUBWEB_API_KEY; call whoami |
| 403 | Key lacks the ability for that tool, or the api_keys feature isn't entitled on the plan | Mint a key with the right ability (see the table at the top); error: "feature_not_entitled" means it's a plan limit |
| 422 | Invalid parameter — or an id you don't own (identical response by design) | Fix the param; verify the id belongs to your account |
| 429 | Per-key rate limit | Honour Retry-After in the body/headers and back off |
| 5xx / 520 | Upstream or edge failure | Retry; a 520 usually means the request never reached the app |
| network error | DNS/TLS/timeout — the client aborts at 20s | Check connectivity and PUBWEB_API_URL |
Recipes
"Which placement should I cut?"
insights_learnings({}) → understand the caveats
insights_placements({ order: "spend", limit: 20 }) → find rows with spend and 0 revenue
insights_adsets({ order: "roi" }) → confirm which ad sets drive them"Is my AdX setup healthy this week?"
adx_kpis({ from: "2026-07-18" }) → aggregate fill rate / viewability
adx_timeseries({ granularity: "hour", from: "2026-07-23" }) → find when it dipped
adx_fill_report({ from: "2026-07-18" }) → which block/country/floor caused it"What is this competitor running?"
targets_list({}) → get the domain / target_id
ads_search({ query: "quiz de emprego", domain: "..." }) → the relevant creatives
ad_media({ id }) + ad_funnel({ id }) → creatives + the click pathSecurity
- Bearer key only — never your password/session. The key is ability-scoped; it can't touch anything outside those abilities. Stored only in your local MCP config.
- Every response is scoped server-side to the key owner: your ads, your sites, your integrations. Hostnames are never a scope — two accounts can own the same domain and neither sees the other's data.
- All tools are read-only except
add_target, which needs the separatev1:ads:writeability. - Rate-limited per key (HTTP 429 +
Retry-Aftersurfaces as a normal tool error).
Releasing (maintainers)
Publishing is automated by .github/workflows/publish-mcp.yml — it fires only on changes
under mcp/ads-mcp/** and publishes to npm only when version here is bumped (an
unbumped change runs but skips publish). So a release is just:
npm version patch # or minor / major — bumps package.json
git commit -am "release(ads-mcp): vX.Y.Z" && git push # → workflow publishesThe version lives in two places and they must match:
package.jsonversionandVERSIONinsrc/index.js.
The package is unscoped — it publishes to the personal npm account that owns the token.
One-time setup by the repo owner: create an automation token on npmjs.com, then add it as
the GitHub repo secret NPM_TOKEN.
