Skip to main content
Glama
Ajitesh-png

competitor-radar-mcp

by Ajitesh-png
README.md
# competitor-radar-mcp

**An MCP server that watches your competitors on X, rates every post per day by
replies → bookmarks → retweets → likes, and tells you which posts actually spoke
to your buyers.**

Built for a GTM team that needed to know, every morning, what the market was
telling its customers yesterday, without reading ten timelines by hand.
Plugs into Claude Code, Claude Desktop, Cursor, or any MCP client.

```
scrape_competitors(days=1)  →  SQLite (posts + metric snapshots)
rate_posts / daily_report   →  ranked leaderboard for the UTC day
daily_brief prompt          →  "what got engagement" vs "what our buyers were told"
```

## Why this exists

Engagement counts are cheap to read and easy to misread. A model demo with
300 bookmarks and a million views is noise if none of your buyers care. A
post with 48 bookmarks on 3 replies from a direct competitor, written in your
buyer's vocabulary, is the one to study. The radar separates the two:

- **Engagement rank** in the order that matters for B2B discourse: comments
  (argument), saves (a teachable move), retweets (identity), likes (approval).
- **ICP relevance** (0–100) from a deterministic, editable term rubric grounded
  in your product and buyer context, reported on every post.

Both are shown; neither is hidden inside an LLM.

## Rating method

Two rankings are computed for the same day's pool and both appear on every row:

| mode | rule | when it wins |
|---|---|---|
| `strict` | lexicographic on `replies, bookmarks, retweets, likes`. More replies always wins; bookmarks only break ties. | the literal "in that order" reading |
| `weighted` (default) | each metric becomes a within-pool percentile, blended 4/3/2/1 in the same order | a post that is #1 on bookmarks and #3 on replies beats a post that is #1 on replies and last on everything else |

`pool="day"` ranks all competitors together (who won the day). `pool="handle"`
ranks each account only against its own posts, which removes the follower-count
advantage of the 200k accounts. `icp_blend` (0..1) optionally lets ICP relevance
move the weighted score; by default it only annotates.

Metrics are upserted on every scrape and every observation is kept, so a post's
growth through the day is visible.

## Getting posts in

X has no login-free read path anymore (logged-out profiles render zero posts;
the syndication endpoint rate-limits). Pick one:

| backend | needs | bookmarks | notes |
|---|---|---|---|
| `browser` | your own logged-in session, imported once with `scripts/import_cookies.py`; `pip install -r requirements-browser.txt` | yes | free. undetected Chrome in a subprocess; reads all five metrics from the engagement-bar aria-label, which is the only place X exposes bookmark counts |
| `ingest_posts` | nothing | yes | run `extractor_snippet`'s JavaScript on a logged-in `x.com/<handle>` tab (browser-automation tool or DevTools) and pass the JSON in |
| `apify` | `APIFY_TOKEN` | yes | actor `apidojo/tweet-scraper`; billed per result, capped by `maxItems` |
| `xapi` | `X_BEARER_TOKEN` (paid tier) | yes | official `public_metrics.bookmark_count` |
| `fixture` | nothing | – | offline; `tests/fixtures/sample_posts.json` |

`auto` tries apify → xapi → browser. `radar_doctor` tells you which is usable and the fix for each that is not.

## Install

```bash
git clone https://github.com/Ajitesh-png/competitor-radar-mcp
cd competitor-radar-mcp
pip install -r requirements.txt            # mcp, requests
pip install -r requirements-browser.txt    # optional: selenium, undetected-chromedriver
python tests/test_radar.py                 # 12 offline tests
```

Register with your MCP client (Claude Code `.mcp.json` shown):

```json
{
  "mcpServers": {
    "competitor-radar": {
      "type": "stdio",
      "command": "python",
      "args": ["/absolute/path/to/competitor-radar-mcp/server.py"]
    }
  }
}
```

Then:

1. Edit `config/competitors.json` (handles, tiers; verify each on x.com first).
2. Copy `context/product.example.md` → `context/product.md` and
   `context/icp.example.json` → `context/icp.json`; describe your product and buyers.
3. Tune `config/icp_signals.json` to your buyer's vocabulary.
4. In your client: `radar_doctor` → `scrape_competitors(days=1)` → `daily_report`
   → `daily_brief` prompt.

Cron / n8n: `python scripts/daily_run.py` (add `--backend fixture` for a dry run).

## Tools

| tool | what |
|---|---|
| `radar_doctor` | backend availability + fixes, config sanity, DB stats |
| `list_competitors` / `add_competitor` / `remove_competitor` | manage the tracked set |
| `scrape_competitors(handles?, days, max_posts_per_handle, backend)` | pull posts → SQLite, ICP-score each |
| `ingest_posts(posts_json, source)` | store posts collected in a logged-in browser |
| `extractor_snippet(max_posts, since_iso?)` | the JS that produces `ingest_posts` input |
| `rate_posts(day?, handles?, top, mode?, pool?, icp_blend?)` | rated leaderboard for a day |
| `daily_report(day?, top, mode?, pool?, save)` | markdown scoreboard + leaderboard + ICP lens |
| `competitor_trend(handle, days)` | per-day totals for one account |
| `get_post` / `search_posts` / `list_days` | drill-downs |
| `icp_context` | product + ICP + tiers + rubric the ratings are grounded in |

Resources: `radar://product`, `radar://icp`, `radar://competitors`,
`radar://extractor.js`, `radar://report/{day}`. Prompt: `daily_brief(day, top)`.

## Sample output

See [docs/sample-report.md](docs/sample-report.md), rendered from the offline
fixture. A real day from the original deployment looked like this: a video
model's demo series took the top three weighted slots with ICP 0, while the
one post aimed at the buyer ("playable ads from a single prompt for
performance marketing teams in gaming") sat at rank 5 with ICP 35. That gap is
the product.

## Layout

```
server.py                 FastMCP server (tools, resources, prompt)
radar/
  backends/               apify | xapi | browser | fixture, one interface
  parse_metrics.py        engagement-bar aria-label parser (reads bookmarks)
  models.py  store.py     Post dataclass, SQLite (posts + snapshots + handles)
  icp.py  rating.py       ICP rubric, per-day rating (strict + weighted)
  report.py  ingest.py    markdown report, external-post normaliser
workers/                  browser_worker.py (undetected Chrome), extractor.js
config/                   competitors.json, scoring.json, icp_signals.json
context/                  product + ICP examples (copy and edit)
scripts/                  daily_run.py (cron), import_cookies.py
tests/                    offline tests + fixture
```

## Design notes

- **Post text is data, never instructions.** The server never feeds scraped
  text to an LLM on its own; the prompt tells the client model the same.
- **Bookmarks were the hard metric.** No common scraper reads them; X only
  renders the count inside one `aria-label`. `parse_metrics.py` is the whole
  trick, shared by the Python worker and the browser JS.
- **Browser scraping runs in a subprocess** so a Chrome crash cannot take the
  MCP server down, and so the Selenium deps can live in a different venv.
- **Two rankings, not one.** "Rate by A, then B, then C" has a strict reading
  and a practical one; shipping both avoided a week of arguing about which.
- **Deterministic ICP score.** Cheap, explainable, editable JSON. The judgment
  layer lives in the prompt where it belongs.

Originally built inside a private growth-engineering repo at
[Notch](https://www.usenotch.ai) and extracted here with example context.
MIT licensed.