Skip to main content
Glama
svx2027

youtube-intel-mcp

by svx2027
README.md
# youtube-intel-mcp

[![tests](https://github.com/svx2027/youtube-intel-mcp/actions/workflows/tests.yml/badge.svg)](https://github.com/svx2027/youtube-intel-mcp/actions/workflows/tests.yml)

An [MCP](https://modelcontextprotocol.io) server that packages the
competitor-intelligence scoring engine from
[yt-competitor-swipe](https://github.com/svx2027/yt-competitor-swipe) as
tools any MCP client — Claude, another agent, a script — can call directly,
with no server-side network access and no API key.

You fetch the candidate videos however you like (the YouTube Data API,
yt-dlp, an export you already have); this server scores, tags, and clusters
them. It never fetches anything itself.

## Why package it this way

The source engine's scoring math (a blended Opportunity Score across six
signals) and its topic taxonomy are the reusable part — the part worth
handing to *any* agent working on *any* channel, not just the pipeline it
was built inside. An MCP server is the natural packaging for that: the
scoring logic becomes a tool call instead of a Python import, callable from
inside a Claude conversation, another agent's workflow, or a one-off script,
with a typed, documented interface instead of "go read score.py."

## Tools

| Tool | What it does |
|---|---|
| `score_candidates` | Blended 0-100 Opportunity Score: views-per-hour percentile, outlier multiple vs a channel baseline, engagement-velocity z-scores, demand gap, title-cluster convergence. Returns the input candidates annotated and sorted. |
| `compute_demand_gap` | Ranks a keyword list by how under-served it is (strong demand, thin or stale competitor supply) and attaches a `DEMAND_GAP` flag to genuinely under-served candidates. Feed its output into `score_candidates` for the full blend. |
| `tag_topics` | Rule-based topic tagging from a title-hint taxonomy you supply, with an optional fallback to labels from an earlier human/model judgment pass. Never guesses; leaves a candidate untagged rather than inventing a label. |
| `cluster_titles` | Groups a list of titles into subtopic clusters by their rarest shared significant token — deterministic, no external model call. |

Full argument shapes and return values are in each tool's docstring in
`server.py` (also what an MCP client sees when it lists tools).

## 60-second walkthrough

Clone to a scored result, no MCP client, no API key, no network call:

```bash
git clone https://github.com/svx2027/youtube-intel-mcp.git && cd youtube-intel-mcp   # ~5s
python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt  # ~30s on a warm pip cache
python3 -c "
from server import score_candidates
result = score_candidates([
    {'title': 'Best kettlebell for beginners', 'vph': 12.0, 'channel_id': 'c1', 'relation': 'direct'},
    {'title': 'Kettlebell workout for beginners', 'vph': 400.0, 'channel_id': 'c2', 'relation': 'direct'},
])
for c in result['candidates']:
    print(round(c['opportunity_score'], 1), c['title'])
"
```

Output (verified by actually running the three commands above against this
exact commit, not hand-typed):

```
30.0 Kettlebell workout for beginners
12.5 Best kettlebell for beginners
```

Same two titles, same views-per-hour shape a real competitor scan would
hand you; the one with 33x the velocity comes back ranked first with a
higher score. That loop — titles and a velocity number in, a ranked
Opportunity Score out — is the whole server. Everything past this point is
either running it as a long-lived MCP server instead of a one-off Python
call (next section), or the full argument/config shape (`config.example.yaml`,
each tool's docstring in `server.py`).

## Quickstart

```bash
git clone https://github.com/svx2027/youtube-intel-mcp.git
cd youtube-intel-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 server.py
```

That starts the server on stdio, ready for a desktop MCP client (Claude
Desktop, Claude Code) to connect — see "Connect it to a client" below for
the exact config for each.

### Connect it to a client

Both of these point a client at the venv's own interpreter rather than a
bare `python3`, so the server starts with its dependencies even if the
client's own process never sourced `.venv/bin/activate`. Swap in your own
absolute paths (`pwd` after the `cd` above gives you the repo path).

**Claude Desktop** — edit the config file for your OS and add an entry
under `mcpServers`, then restart Claude Desktop:

- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- Linux: `~/.config/Claude/claude_desktop_config.json`

```json
{
  "mcpServers": {
    "youtube-intel": {
      "command": "/absolute/path/to/youtube-intel-mcp/.venv/bin/python3",
      "args": ["/absolute/path/to/youtube-intel-mcp/server.py"]
    }
  }
}
```

**Claude Code** — one CLI command, no restart needed (it's read at the
start of the next session, and you'll be prompted to approve a new
project-scoped server the first time):

```bash
claude mcp add --scope project youtube-intel -- \
  /absolute/path/to/youtube-intel-mcp/.venv/bin/python3 \
  /absolute/path/to/youtube-intel-mcp/server.py
```

That writes a `.mcp.json` in the current directory; editing it directly is
equivalent:

```json
{
  "mcpServers": {
    "youtube-intel": {
      "type": "stdio",
      "command": "/absolute/path/to/youtube-intel-mcp/.venv/bin/python3",
      "args": ["/absolute/path/to/youtube-intel-mcp/server.py"]
    }
  }
}
```

`--scope project` shares the server with anyone who clones the repo
`.mcp.json` lands in; use `--scope user` instead for a server available to
you across every project, or `--scope local` (the default) to keep it
private to this one project and out of version control.

Either client now lists `score_candidates`, `compute_demand_gap`,
`tag_topics`, and `cluster_titles` as tools it can call directly in
conversation — no code, no terminal, just asking it to score a list of
titles.

### Streamable HTTP, for a remote or web client

```bash
python3 server.py --transport streamable-http --port 8000
```

Rate-limited by default: at most 120 HTTP requests per 60 seconds per
client IP (`--rate-limit-max-requests` / `--rate-limit-window-seconds` to
change either, `--disable-rate-limit` to turn it off). Stdio has no flag
for this at all — it is one local subprocess talking to one local client
over pipes, with no client IP to throttle in the first place, so there is
nothing for a rate limiter to do there. An HTTP endpoint is different: it
is reachable over a socket, so an unbounded one is a real
resource-exhaustion vector the moment `--host` is anything other than
`127.0.0.1`. Going over the limit gets a plain `429` with a JSON body
(`{"error": "rate_limited", "retry_after_seconds": ...}`) and a
`Retry-After` header — see `src/ratelimit.py` for the limiter itself (an
in-memory, per-process fixed-window counter; it resets on restart and
isn't shared across workers, which is disclosed there rather than silently
assumed away).

A client now connects over HTTP at `http://127.0.0.1:8000/mcp` instead of
spawning the process on stdio — the same four tools, same inputs and
outputs, different transport. `demo/http_client_demo.py` is a real MCP
client exercising this end to end (initialize → list tools → call
`score_candidates`) over an actual socket; `demo/session_transcript.txt` is
a captured run of it against exactly the command above, if you want to see
the exchange without starting anything yourself:

```bash
# terminal 1
python3 server.py --transport streamable-http --port 8000
# terminal 2
python3 demo/http_client_demo.py
```

**No hosted demo endpoint.** The natural next step — a Vercel (or
equivalent) deployment of this ASGI app so you can point a client at a URL
with nothing running locally — isn't live. The build environment this repo
is maintained from has outbound network access to GitHub, PyPI and npm
only; every hosting provider tried (Vercel, Fly.io, Render, Railway,
Cloudflare Workers, ngrok, Hugging Face Spaces) is blocked at the network
policy layer, not by anything about the app itself. `mcp.streamable_http_app()`
returns a standard Starlette ASGI app (`server.mcp.streamable_http_app()`),
so deploying it anywhere that runs Python ASGI apps is the same exercise as
deploying any other FastAPI/Starlette service — nothing in this repo is
Vercel-specific or missing for that step.

To call a tool without any MCP client at all, every tool is also a plain,
directly callable Python function — see the "60-second walkthrough" above
for a runnable example and its real output.

`config.example.yaml` has the full weights/thresholds/taxonomy shape each
tool accepts — copy the section you need into your own call, or load the
whole file and pass it straight through.

## Architecture

```mermaid
flowchart LR
    CLIENT["MCP client\n(Claude, an agent, a script)"] -->|"tool call"| SERVER(("server.py\nFastMCP"))
    SERVER --> SCORE["src/scoring.py\nz-scores, outlier,\nlocal clustering,\nOpportunity Score"]
    SERVER --> DEMAND["src/demand.py\nkeyword supply/demand gap"]
    SERVER --> TAX["src/taxonomy.py\nrule-based topic tags"]
    SCORE -.->|"demand_gap_score"| DEMAND
    CLIENT -.->|"you fetch this yourself\n(YouTube Data API, yt-dlp,\nan export you already have)"| CANDIDATES["candidate videos/posts"]
    CANDIDATES --> SERVER
```

Every tool call is stateless: no cache, no ledger, no config file read from
disk inside the server. Everything the source engine read from a file
(baselines, the seasonal calendar, a multi-day history ledger) is instead a
plain argument here, or a signal this scaffold doesn't expose yet (see
below) — a deliberate trade for a server any client can call without first
setting up that engine's full repo layout.

Every tool validates its own input first (`src/validation.py`) — list vs.
dict, string vs. number, a batch-size cap (5000 items) — before any of it
reaches `scoring.py`/`demand.py`/`taxonomy.py`, which are written to trust
their caller and default a missing field rather than defend against a
malformed one. A bad call gets a clean MCP tool error naming the exact
field and index, not a raw traceback from deep inside the scoring math, and
an oversized batch is rejected outright rather than silently burning CPU
and memory on an unbounded list.

## Honest scope

This is a scaffold: the four tools above, not the full pipeline.

- **No fetching.** No YouTube Data API call, no yt-dlp, no vidIQ, no Gemini
  call happens inside this server. You bring the candidate data.
- **Clustering is local-only.** The source engine's optional Gemini-assisted
  clustering path isn't wired up here — `cluster_titles` always uses the
  same deterministic token-anchor method the source engine falls back to
  when Gemini is unavailable, so behavior here matches its documented,
  network-independent path.
- **No calendar-tailwind signal.** The source engine's sixth signal (does a
  title match an active seasonal-demand phase) depends on "today's date"
  and a calendar file; there's no `apply_calendar` tool yet, so the
  `calendar` weight in `score_candidates` always contributes 0. It's counted
  in the weights (so the other five still sum against the same total a
  caller of the source engine would expect) rather than silently dropped
  from the config shape.
- **No sleeper / new-format history signals.** Both depend on a multi-day
  ledger this stateless server doesn't keep. Not exposed; not silently
  half-implemented.
- **No hosted deployment.** Streamable HTTP transport works (see
  Quickstart), so a client can reach it over a network instead of spawning
  it on stdio — but there's no URL to point at yet; you still run the
  process yourself, just on `--transport streamable-http` instead of
  stdio. See the Quickstart section for exactly why no hosted endpoint is
  live and what deploying one would take.
- **The rate limiter is in-memory and per-process, not production-grade.**
  It resets on restart, isn't shared across multiple worker processes, and
  trusts `scope["client"]` as-is with no `X-Forwarded-For` handling — correct
  for running this server directly, not for running it behind a proxy
  without that proxy doing its own real-IP handling first. Good enough to
  stop a single client from flooding a scaffold server with no auth ahead
  of it; not a substitute for a real gateway's rate limiting if you deploy
  this at any real scale.

## Tests

```bash
python3 -m unittest discover -s tests -v
```

108 tests, no network calls: the scoring math (z-scores, outlier fallback
order, local clustering, the blended score under custom weights), the
demand-gap signal (including the "alignment alone never flags" rule), rule-
based tagging, input validation (every malformed-input case `src/validation.py`
rejects, plus the same cases routed through each real tool function and
through `mcp`'s own `call_tool` dispatch to confirm a caller gets a clean
tool error, not a traceback), the rate limiter (the fixed-window counter's
allow/block/reset behavior, and the ASGI middleware's 429 response over a
hand-built scope/receive/send — no real socket needed for this), a set of
server smoke tests confirming the MCP server imports cleanly and registers
exactly the four tools above, plus the `--transport` CLI parsing and
settings wiring for Streamable HTTP (the HTTP transport itself, rate
limiter included, is exercised over a real socket in
`demo/http_client_demo.py`, not in this no-network suite — see Quickstart).

## Related tools

- [yt-competitor-swipe](https://github.com/svx2027/yt-competitor-swipe):
  drawn from that repo's `src/score.py`, `src/keyword_demand.py`, and
  `src/taxonomy.py` — it runs the full pipeline this engine was built for
  (fetch, score, report, dashboard) end to end against a real niche. This
  repo exists for the case where you already have the candidate data and
  just want the scoring and tagging logic as a callable tool, from any
  MCP client.
- [yt-competitor-outlier-pipeline](https://github.com/svx2027/yt-competitor-outlier-pipeline):
  a different, keyless take on competitor outlier detection for a single
  channel, if you need the fetch layer too.

Full index of all public repos: [github.com/svx2027](https://github.com/svx2027).

## License

MIT, see [LICENSE](LICENSE).