npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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:

  1. 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.
  2. Your AdX telemetry — the ad-serving funnel (requests → fills → viewables → clicks) for the sites you own.
  3. 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" to env (default https://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.

Gotchasmeta.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.

Gotchashour 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_slice is true: the revenue total here is a UTM slice, not the authoritative window total. Compare against meta.reference.net_usd (from gam_reports_adunit) and meta.reference.utm_coverage_pct.
  • Spend at this grain reads only rows where placement <> ''. The table also stores an aggregate row per (adset, day) with placement = ''; summing both would double every dollar. meta.quality.placement_spend_reconciles tells you whether the two grains agree — if false, the placement sync is corrupt and the split is untrustworthy (the aggregate total is still right).
  • spend.reach and spend.frequency are always null — 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_share is a separate field and net_usd is what you actually keep. Never compare gross_usd against spend.
  • FX uses each row's own date. If meta.partial is true, some (currency, day) rates are missing (meta.fx_missing_days), and affected rows carry roi: 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_id you 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_term carries the same adset id in GAM — summing both double-counts, which is why the API deliberately ignores it.
  • reach is null and reach_daily_max is offered instead: reach isn't additive across days either.
  • Row-level quality.placement_spend_reconciles flags 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 site can be null even 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: AI |

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 thisbefore 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

  1. Check meta.partial and meta.warnings first. partial: true means some FX rates were missing and USD figures cover only the rated days — the fix is a fx:backfill run on the server, not a retry.
  2. meta.totals.revenue_total_is_utm_slice: true — the revenue total is a UTM slice. The authoritative window revenue is meta.reference.net_usd (from gam_reports_adunit); meta.reference.utm_coverage_pct tells you how much of it the UTM slice captured.
  3. meta.qualityplacement_spend_reconciles: false means the placement split is corrupt for at least one (adset, day); worst_mismatches names them. The aggregate spend total is still correct.
  4. spend.reach is intentionally null. Not missing data — non-additive by nature.
  5. A row with roi: null carries roi_suppressed_reason (e.g. "fx_incomplete"). A suppressed ROI is deliberate: a number computed over mismatched day coverage would be plausible and wrong.
  6. 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 path

Security

  • 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 separate v1:ads:write ability.
  • Rate-limited per key (HTTP 429 + Retry-After surfaces 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 publishes

The version lives in two places and they must match: package.json version and VERSION in src/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.