mcp-demand-radar 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Packaging
8
+ build/
9
+ dist/
10
+ *.egg-info/
11
+ .eggs/
12
+ wheels/
13
+
14
+ # Environnements virtuels
15
+ .venv/
16
+ venv/
17
+ env/
18
+
19
+ # Tests et outils
20
+ .pytest_cache/
21
+ .coverage
22
+ htmlcov/
23
+ .mypy_cache/
24
+ .ruff_cache/
25
+ .tox/
26
+
27
+ # Secrets et configuration locale
28
+ .env
29
+ .env.*
30
+ *.pem
31
+ *.key
32
+
33
+ # Radar : configuration réelle et sorties (seul radar.example.toml est versionné)
34
+ radar.toml
35
+ *.csv
36
+ rapport*.html
37
+
38
+ # Éditeurs et système
39
+ .vscode/
40
+ .idea/
41
+ .DS_Store
42
+ Thumbs.db
43
+ radar-data/
44
+ radar-out/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jérémie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-demand-radar
3
+ Version: 0.1.0
4
+ Summary: Track GitHub demand for MCP servers and find the gaps nobody fills yet.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: cli,github,market-research,mcp
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == 'test'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # mcp-demand-radar
16
+
17
+ Track which MCP servers GitHub users are asking for, and which of those requests nobody has built yet.
18
+
19
+ Standard library only, Python 3.11+, no dependencies.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install mcp-demand-radar
25
+ ```
26
+
27
+ The command it installs is `mcp-radar`.
28
+
29
+ ## Try it without a token or network
30
+
31
+ ```bash
32
+ mcp-radar scan --demo
33
+ mcp-radar gaps --demo
34
+ mcp-radar report --demo --out radar-out
35
+ ```
36
+
37
+ Demo mode uses fictional offline data.
38
+
39
+ ## Real use
40
+
41
+ ```bash
42
+ export GITHUB_TOKEN=... # optional but recommended (5,000 requests/hour instead of 60)
43
+ cp radar.example.toml radar.toml # then adapt your categories
44
+ mcp-radar report
45
+ ```
46
+
47
+ `radar.toml` is read from the current directory (or `--config path`). Keep it out of version control; the package only ships a neutral `radar.example.toml`.
48
+
49
+ ## Commands
50
+
51
+ | Command | What it does |
52
+ |---|---|
53
+ | `mcp-radar scan` | Popular repositories for each configured category (most starred MCP servers, newcomers…). |
54
+ | `mcp-radar demand` | Open GitHub issues asking for an MCP server, ranked by reactions. |
55
+ | `mcp-radar gaps` | For each request, counts existing MCP servers with ≥10 stars; score = reactions / (1 + that count). |
56
+ | `mcp-radar report` | Runs everything, writes `report.html` and `report.csv`, stores a dated snapshot in `data_dir` and shows star gains since the previous snapshot. |
57
+
58
+ All commands accept `--config`, `--demo` and `--json` (`report` also `--out`).
59
+
60
+ ## Configuration
61
+
62
+ See `radar.example.toml`: custom categories (`{days30}` / `{days90}` expand to dates), demand queries, minimum reactions, stopwords used to extract a request's subject.
63
+
64
+ ## Using it from Claude Code / Claude Desktop
65
+
66
+ This is a CLI, not an MCP server. In Claude Code, just ask: "run `mcp-radar gaps` and summarise the top 5 opportunities". Claude Desktop cannot run shell commands; open `report.html` in a browser instead.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ pip install -e ".[test]"
72
+ pytest # offline, uses simulated GitHub responses
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,63 @@
1
+ # mcp-demand-radar
2
+
3
+ Track which MCP servers GitHub users are asking for, and which of those requests nobody has built yet.
4
+
5
+ Standard library only, Python 3.11+, no dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install mcp-demand-radar
11
+ ```
12
+
13
+ The command it installs is `mcp-radar`.
14
+
15
+ ## Try it without a token or network
16
+
17
+ ```bash
18
+ mcp-radar scan --demo
19
+ mcp-radar gaps --demo
20
+ mcp-radar report --demo --out radar-out
21
+ ```
22
+
23
+ Demo mode uses fictional offline data.
24
+
25
+ ## Real use
26
+
27
+ ```bash
28
+ export GITHUB_TOKEN=... # optional but recommended (5,000 requests/hour instead of 60)
29
+ cp radar.example.toml radar.toml # then adapt your categories
30
+ mcp-radar report
31
+ ```
32
+
33
+ `radar.toml` is read from the current directory (or `--config path`). Keep it out of version control; the package only ships a neutral `radar.example.toml`.
34
+
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---|---|
39
+ | `mcp-radar scan` | Popular repositories for each configured category (most starred MCP servers, newcomers…). |
40
+ | `mcp-radar demand` | Open GitHub issues asking for an MCP server, ranked by reactions. |
41
+ | `mcp-radar gaps` | For each request, counts existing MCP servers with ≥10 stars; score = reactions / (1 + that count). |
42
+ | `mcp-radar report` | Runs everything, writes `report.html` and `report.csv`, stores a dated snapshot in `data_dir` and shows star gains since the previous snapshot. |
43
+
44
+ All commands accept `--config`, `--demo` and `--json` (`report` also `--out`).
45
+
46
+ ## Configuration
47
+
48
+ See `radar.example.toml`: custom categories (`{days30}` / `{days90}` expand to dates), demand queries, minimum reactions, stopwords used to extract a request's subject.
49
+
50
+ ## Using it from Claude Code / Claude Desktop
51
+
52
+ This is a CLI, not an MCP server. In Claude Code, just ask: "run `mcp-radar gaps` and summarise the top 5 opportunities". Claude Desktop cannot run shell commands; open `report.html` in a browser instead.
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ pip install -e ".[test]"
58
+ pytest # offline, uses simulated GitHub responses
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-demand-radar"
7
+ version = "0.1.0"
8
+ description = "Track GitHub demand for MCP servers and find the gaps nobody fills yet."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+ keywords = ["mcp", "github", "market-research", "cli"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Environment :: Console",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest"]
22
+
23
+ [project.scripts]
24
+ mcp-radar = "mcp_radar.cli:main"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/mcp_radar"]
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = ["src", "tests", "README.md", "LICENSE", "radar.example.toml"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,26 @@
1
+ # Example configuration. Copy to radar.toml (never committed) and adapt.
2
+ # Categories here are deliberately generic.
3
+
4
+ [radar]
5
+ data_dir = "radar-data" # where snapshots are stored
6
+ top = 20 # repositories per category
7
+
8
+ [[categories]]
9
+ id = "mcp_top"
10
+ label = "Most starred MCP servers"
11
+ query = "topic:mcp-server"
12
+
13
+ [[categories]]
14
+ id = "mcp_new"
15
+ label = "New MCP servers (last 90 days)"
16
+ query = "mcp server in:name,description created:>{days90}" # {days30}/{days90} are replaced by dates
17
+
18
+ [demand]
19
+ # Open issues asking for an MCP server; ranked by reactions.
20
+ queries = ["mcp server in:title is:issue is:open"]
21
+ min_reactions = 3
22
+ top = 20
23
+
24
+ [gaps]
25
+ # A demand's subject is its issue title minus these words.
26
+ stopwords = ["mcp", "server", "add", "support", "request", "feature", "for", "a", "an", "the", "new", "please"]
@@ -0,0 +1,3 @@
1
+ """mcp-radar: GitHub demand and supply tracking for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ """scan / demand / gaps logic, independent of the CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .config import expand_query
9
+ from .github import GitHub
10
+
11
+
12
+ def scan(gh: GitHub, cfg: dict) -> dict:
13
+ top = cfg["radar"]["top"]
14
+ out = {}
15
+ for cat in cfg["categories"]:
16
+ total, repos = gh.search_repos(expand_query(cat["query"]), top)
17
+ out[cat["id"]] = {"label": cat["label"], "query": cat["query"], "total": total, "repos": repos}
18
+ return out
19
+
20
+
21
+ def demand(gh: GitHub, cfg: dict) -> list[dict]:
22
+ d = cfg["demand"]
23
+ seen, items = set(), []
24
+ for q in d["queries"]:
25
+ for it in gh.search_issues(expand_query(q), d["top"]):
26
+ if it["url"] in seen or it["reactions"] < d["min_reactions"]:
27
+ continue
28
+ seen.add(it["url"])
29
+ items.append(it)
30
+ items.sort(key=lambda x: (-x["reactions"], -x["comments"]))
31
+ return items[: d["top"]]
32
+
33
+
34
+ def subject_of(title: str, stopwords: list[str]) -> str:
35
+ words = re.findall(r"[\w.+-]+", title.lower())
36
+ stop = {w.lower() for w in stopwords}
37
+ return " ".join(w for w in words if w not in stop).strip()
38
+
39
+
40
+ def gaps(gh: GitHub, cfg: dict, demands: list[dict]) -> list[dict]:
41
+ """For each demand, count existing MCP servers on the same subject.
42
+
43
+ gap_score = reactions x 1 / (1 + existing servers that are 'serious',
44
+ i.e. at least 10 stars). No existing server -> score equals reactions.
45
+ """
46
+ stop = cfg["gaps"]["stopwords"]
47
+ rows = []
48
+ for d in demands:
49
+ subject = subject_of(d["title"], stop)
50
+ if not subject:
51
+ continue
52
+ total, repos = gh.search_repos(f"{subject} mcp in:name,description", 10)
53
+ serious = [r for r in repos if r["stars"] >= 10]
54
+ rows.append({
55
+ "subject": subject, "reactions": d["reactions"], "issue": d["url"],
56
+ "existing": total, "serious": len(serious),
57
+ "best_stars": max((r["stars"] for r in repos), default=0),
58
+ "gap_score": round(d["reactions"] / (1 + len(serious)), 2),
59
+ })
60
+ rows.sort(key=lambda r: -r["gap_score"])
61
+ return rows
62
+
63
+
64
+ # ---------- snapshots ----------
65
+ def save_snapshot(data_dir: Path, today: str, payload: dict) -> Path:
66
+ data_dir.mkdir(parents=True, exist_ok=True)
67
+ p = data_dir / f"snapshot_{today}.json"
68
+ p.write_text(json.dumps({"date": today, **payload}, ensure_ascii=False, indent=1), encoding="utf-8")
69
+ return p
70
+
71
+
72
+ def load_previous(data_dir: Path, today: str) -> dict | None:
73
+ snaps = sorted(s for s in data_dir.glob("snapshot_*.json") if s.stem != f"snapshot_{today}")
74
+ return json.loads(snaps[-1].read_text(encoding="utf-8")) if snaps else None
75
+
76
+
77
+ def star_gains(current: dict, previous: dict | None) -> dict[str, int]:
78
+ if not previous:
79
+ return {}
80
+ old = {r["repo"]: r["stars"] for c in previous.get("scan", {}).values() for r in c["repos"]}
81
+ return {r["repo"]: r["stars"] - old[r["repo"]]
82
+ for c in current.values() for r in c["repos"] if r["repo"] in old}
83
+
@@ -0,0 +1,94 @@
1
+ """Command line: mcp-radar scan | demand | gaps | report."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime as dt
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .analysis import (demand, gaps, load_previous, save_snapshot, scan, star_gains)
12
+ from .config import load_config
13
+ from .demo import demo_transport
14
+ from .github import GitHub, http_transport
15
+ from .report import render_html, write_csv
16
+
17
+
18
+ def _print_table(rows: list[list], headers: list[str]) -> None:
19
+ widths = [max(len(str(x)) for x in col) for col in zip(headers, *rows)] if rows else [len(h) for h in headers]
20
+ for r in [headers, *rows]:
21
+ print(" ".join(str(c).ljust(w) for c, w in zip(r, widths)).rstrip())
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ ap = argparse.ArgumentParser(prog="mcp-radar", description="GitHub demand radar for MCP servers.")
26
+ ap.add_argument("--version", action="version", version=__version__)
27
+ common = argparse.ArgumentParser(add_help=False)
28
+ common.add_argument("--config", help="path to radar.toml (default: ./radar.toml if present)")
29
+ common.add_argument("--demo", action="store_true", help="use fictional offline data (no network, no token)")
30
+ common.add_argument("--json", action="store_true", help="print JSON instead of a table")
31
+ sub = ap.add_subparsers(dest="command", required=True)
32
+ sub.add_parser("scan", parents=[common], help="popular repositories per category")
33
+ sub.add_parser("demand", parents=[common], help="issues asking for an MCP, ranked by reactions")
34
+ sub.add_parser("gaps", parents=[common], help="demand x missing supply score")
35
+ rp = sub.add_parser("report", parents=[common], help="HTML + CSV report with progression")
36
+ rp.add_argument("--out", default="radar-out", help="output directory (default: radar-out)")
37
+ return ap
38
+
39
+
40
+ def main(argv: list[str] | None = None, transport=None) -> int:
41
+ args = build_parser().parse_args(argv)
42
+ try:
43
+ cfg = load_config(args.config)
44
+ except (FileNotFoundError, ValueError) as e:
45
+ print(f"Error: {e}", file=sys.stderr)
46
+ return 2
47
+ transport = transport or (demo_transport if args.demo else http_transport)
48
+ gh = GitHub(transport, pause=0 if args.demo or transport is not http_transport else 2)
49
+ today = dt.date.today().isoformat()
50
+ try:
51
+ if args.command == "scan":
52
+ res = scan(gh, cfg)
53
+ if args.json:
54
+ print(json.dumps(res, indent=1))
55
+ else:
56
+ for c in res.values():
57
+ print(f"\n{c['label']} ({c['total']} repositories)")
58
+ _print_table([[r["repo"], r["stars"], r["language"]] for r in c["repos"]],
59
+ ["repo", "stars", "language"])
60
+ elif args.command == "demand":
61
+ res = demand(gh, cfg)
62
+ if args.json:
63
+ print(json.dumps(res, indent=1))
64
+ else:
65
+ _print_table([[d["reactions"], d["title"], d["url"]] for d in res], ["reactions", "request", "url"])
66
+ elif args.command == "gaps":
67
+ res = gaps(gh, cfg, demand(gh, cfg))
68
+ if args.json:
69
+ print(json.dumps(res, indent=1))
70
+ else:
71
+ _print_table([[g["gap_score"], g["subject"], g["reactions"], g["serious"]] for g in res],
72
+ ["score", "subject", "reactions", "existing>=10*"])
73
+ else:
74
+ data_dir = Path(cfg["radar"]["data_dir"])
75
+ out = Path(args.out)
76
+ out.mkdir(parents=True, exist_ok=True)
77
+ sc = scan(gh, cfg)
78
+ dm = demand(gh, cfg)
79
+ gp = gaps(gh, cfg, dm)
80
+ prev = load_previous(data_dir, today)
81
+ gains = star_gains(sc, prev)
82
+ save_snapshot(data_dir, today, {"scan": sc, "demand": dm, "gaps": gp})
83
+ (out / "report.html").write_text(
84
+ render_html(today, sc, dm, gp, gains, prev["date"] if prev else None), encoding="utf-8")
85
+ write_csv(out / "report.csv", sc, gains)
86
+ print(f"Report written to {out / 'report.html'} and {out / 'report.csv'}")
87
+ except Exception as e: # network / API errors
88
+ print(f"Error: {e}", file=sys.stderr)
89
+ return 1
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ sys.exit(main())
@@ -0,0 +1,49 @@
1
+ """Configuration loading (radar.toml) with neutral defaults."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ DEFAULT_CATEGORIES = [
9
+ {"id": "mcp_top", "label": "Most starred MCP servers", "query": "topic:mcp-server"},
10
+ {"id": "mcp_new", "label": "New MCP servers (last 90 days)",
11
+ "query": "mcp server in:name,description created:>{days90}"},
12
+ ]
13
+ DEFAULT_STOPWORDS = ["mcp", "server", "add", "support", "request", "feature", "for",
14
+ "a", "an", "the", "new", "please", "model", "context", "protocol"]
15
+
16
+
17
+ def default_config() -> dict:
18
+ return {
19
+ "radar": {"data_dir": "radar-data", "top": 20},
20
+ "categories": [dict(c) for c in DEFAULT_CATEGORIES],
21
+ "demand": {"queries": ["mcp server in:title is:issue is:open"],
22
+ "min_reactions": 1, "top": 20},
23
+ "gaps": {"stopwords": list(DEFAULT_STOPWORDS)},
24
+ }
25
+
26
+
27
+ def load_config(path: str | Path | None = None) -> dict:
28
+ """Load `path`, or ./radar.toml if present, else defaults."""
29
+ cfg = default_config()
30
+ if path is None:
31
+ candidate = Path("radar.toml")
32
+ path = candidate if candidate.exists() else None
33
+ if path is None:
34
+ return cfg
35
+ p = Path(path)
36
+ if not p.exists():
37
+ raise FileNotFoundError(f"Config file not found: {p}")
38
+ user = tomllib.loads(p.read_text(encoding="utf-8"))
39
+ for section in ("radar", "demand", "gaps"):
40
+ cfg[section].update(user.get(section, {}))
41
+ if user.get("categories"):
42
+ cfg["categories"] = user["categories"]
43
+ return cfg
44
+
45
+
46
+ def expand_query(query: str, today: dt.date | None = None) -> str:
47
+ today = today or dt.date.today()
48
+ return (query.replace("{days30}", (today - dt.timedelta(days=30)).isoformat())
49
+ .replace("{days90}", (today - dt.timedelta(days=90)).isoformat()))
@@ -0,0 +1,32 @@
1
+ """Offline demo transport: fictional repositories and issues, no network."""
2
+ from __future__ import annotations
3
+
4
+ import urllib.parse
5
+
6
+ _REPOS = [
7
+ {"full_name": "acme/demo-mcp", "html_url": "https://example.invalid/acme/demo-mcp",
8
+ "description": "Fictional demo server", "stargazers_count": 1200, "forks_count": 80,
9
+ "language": "Python", "topics": ["mcp-server"], "created_at": "2026-01-10T00:00:00Z",
10
+ "pushed_at": "2026-11-01T00:00:00Z"},
11
+ {"full_name": "acme/sample-mcp", "html_url": "https://example.invalid/acme/sample-mcp",
12
+ "description": "Another fictional server", "stargazers_count": 40, "forks_count": 3,
13
+ "language": "TypeScript", "topics": ["mcp-server"], "created_at": "2026-09-01T00:00:00Z",
14
+ "pushed_at": "2026-11-05T00:00:00Z"},
15
+ ]
16
+ _ISSUES = [
17
+ {"title": "Add Foodtracker MCP server", "reactions": {"total_count": 14}, "comments": 5},
18
+ {"title": "Request: Widgetbase MCP", "reactions": {"total_count": 9}, "comments": 2},
19
+ {"title": "Add Demo MCP server", "reactions": {"total_count": 4}, "comments": 1},
20
+ ]
21
+
22
+
23
+ def demo_transport(url: str) -> dict:
24
+ parsed = urllib.parse.urlparse(url)
25
+ q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0].lower()
26
+ if parsed.path.endswith("search/issues"):
27
+ return {"items": [{
28
+ "repository_url": "https://api.github.com/repos/example/registry",
29
+ "html_url": f"https://example.invalid/issues/{n}", "created_at": "2026-10-01T00:00:00Z", **i}
30
+ for n, i in enumerate(_ISSUES, 1)]}
31
+ items = [r for r in _REPOS if "demo" in q or "sample" in q] if " mcp in:" in q else list(_REPOS)
32
+ return {"total_count": len(items), "items": items}
@@ -0,0 +1,68 @@
1
+ """Minimal GitHub Search client. The transport is injectable so tests and
2
+ demo mode never touch the network."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Callable
12
+
13
+ Transport = Callable[[str], dict]
14
+
15
+
16
+ def http_transport(url: str) -> dict:
17
+ headers = {"User-Agent": "mcp-radar", "Accept": "application/vnd.github+json"}
18
+ token = os.environ.get("GITHUB_TOKEN")
19
+ if token:
20
+ headers["Authorization"] = f"Bearer {token}"
21
+ for attempt in range(3):
22
+ try:
23
+ req = urllib.request.Request(url, headers=headers)
24
+ with urllib.request.urlopen(req, timeout=30) as r:
25
+ return json.load(r)
26
+ except urllib.error.HTTPError as e:
27
+ if e.code in (403, 429) and attempt < 2:
28
+ time.sleep(10 if token else 60)
29
+ continue
30
+ raise
31
+ raise RuntimeError("unreachable")
32
+
33
+
34
+ class GitHub:
35
+ def __init__(self, transport: Transport = http_transport, pause: float = 0.0):
36
+ self.transport = transport
37
+ self.pause = pause
38
+
39
+ def _get(self, path: str, params: dict) -> dict:
40
+ data = self.transport("https://api.github.com/" + path + "?" + urllib.parse.urlencode(params))
41
+ if self.pause:
42
+ time.sleep(self.pause)
43
+ return data
44
+
45
+ def search_repos(self, query: str, top: int = 20) -> tuple[int, list[dict]]:
46
+ data = self._get("search/repositories",
47
+ {"q": query, "sort": "stars", "order": "desc", "per_page": min(top, 100)})
48
+ repos = [{
49
+ "repo": r["full_name"], "url": r["html_url"],
50
+ "description": (r.get("description") or "").strip(),
51
+ "stars": r.get("stargazers_count", 0), "forks": r.get("forks_count", 0),
52
+ "language": r.get("language") or "", "topics": r.get("topics", []),
53
+ "created": (r.get("created_at") or "")[:10], "updated": (r.get("pushed_at") or "")[:10],
54
+ } for r in data.get("items", [])]
55
+ return data.get("total_count", 0), repos
56
+
57
+ def search_issues(self, query: str, top: int = 20) -> list[dict]:
58
+ data = self._get("search/issues",
59
+ {"q": query, "sort": "reactions", "order": "desc", "per_page": min(top, 100)})
60
+ out = []
61
+ for i in data.get("items", []):
62
+ repo = i.get("repository_url", "").removeprefix("https://api.github.com/repos/")
63
+ out.append({
64
+ "repo": repo, "title": i.get("title", ""), "url": i.get("html_url", ""),
65
+ "reactions": (i.get("reactions") or {}).get("total_count", 0),
66
+ "comments": i.get("comments", 0), "created": (i.get("created_at") or "")[:10],
67
+ })
68
+ return out
@@ -0,0 +1,55 @@
1
+ """HTML + CSV report rendering."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import html
6
+ from pathlib import Path
7
+
8
+ CSS = ("body{font:15px/1.5 system-ui,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem;"
9
+ "color:#1d2433}table{border-collapse:collapse;width:100%;margin:.5rem 0 2rem}"
10
+ "th,td{border-bottom:1px solid #dde1e8;padding:.35rem .5rem;text-align:left}"
11
+ "td.n{text-align:right;font-variant-numeric:tabular-nums}small{display:block;color:#5b6478}"
12
+ "@media(prefers-color-scheme:dark){body{background:#12161e;color:#e6e9ef}"
13
+ "a{color:#3cc4bb}th,td{border-color:#2a3140}}")
14
+
15
+
16
+ def _e(s) -> str:
17
+ return html.escape(str(s))
18
+
19
+
20
+ def render_html(date: str, scan: dict, demands: list[dict], gap_rows: list[dict],
21
+ gains: dict[str, int], prev_date: str | None) -> str:
22
+ parts = [f"<h1>MCP radar – {_e(date)}</h1>"]
23
+ parts.append(f"<p>Gains since {_e(prev_date)}.</p>" if prev_date
24
+ else "<p>First snapshot: run again next week to see star gains.</p>")
25
+ for cid, c in scan.items():
26
+ rows = "".join(
27
+ f'<tr><td><a href="{_e(r["url"])}">{_e(r["repo"])}</a><small>{_e(r["description"][:140])}</small></td>'
28
+ f'<td class="n">{r["stars"]}</td>'
29
+ f'<td class="n">{("+%d" % gains[r["repo"]]) if r["repo"] in gains else "–"}</td></tr>'
30
+ for r in c["repos"])
31
+ parts.append(f"<h2>{_e(c['label'])}</h2><p>{c['total']} matching repositories.</p>"
32
+ f"<table><tr><th>Repository</th><th>Stars</th><th>Gain</th></tr>{rows}</table>")
33
+ if demands:
34
+ rows = "".join(f'<tr><td><a href="{_e(d["url"])}">{_e(d["title"])}</a><small>{_e(d["repo"])}</small></td>'
35
+ f'<td class="n">{d["reactions"]}</td></tr>' for d in demands)
36
+ parts.append(f"<h2>Demand</h2><table><tr><th>Request</th><th>Reactions</th></tr>{rows}</table>")
37
+ if gap_rows:
38
+ rows = "".join(f'<tr><td><a href="{_e(g["issue"])}">{_e(g["subject"])}</a></td>'
39
+ f'<td class="n">{g["reactions"]}</td><td class="n">{g["serious"]}</td>'
40
+ f'<td class="n">{g["gap_score"]}</td></tr>' for g in gap_rows)
41
+ parts.append("<h2>Gaps</h2><table><tr><th>Subject</th><th>Demand</th><th>Existing (≥10★)</th>"
42
+ f"<th>Score</th></tr>{rows}</table>")
43
+ return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
44
+ f'<meta name="viewport" content="width=device-width,initial-scale=1">'
45
+ f"<title>MCP radar {_e(date)}</title><style>{CSS}</style></head><body>{''.join(parts)}</body></html>")
46
+
47
+
48
+ def write_csv(path: Path, scan: dict, gains: dict[str, int]) -> None:
49
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
50
+ w = csv.writer(f, delimiter=";")
51
+ w.writerow(["category", "repo", "stars", "gain", "forks", "language", "created", "url"])
52
+ for cid, c in scan.items():
53
+ for r in c["repos"]:
54
+ w.writerow([cid, r["repo"], r["stars"], gains.get(r["repo"], ""), r["forks"],
55
+ r["language"], r["created"], r["url"]])
@@ -0,0 +1,103 @@
1
+ import json
2
+ import urllib.parse
3
+
4
+ import pytest
5
+
6
+ from mcp_radar import analysis, cli
7
+ from mcp_radar.config import default_config, expand_query, load_config
8
+ from mcp_radar.github import GitHub
9
+
10
+
11
+ def fake_transport(calls=None, stars=100):
12
+ def t(url):
13
+ if calls is not None:
14
+ calls.append(url)
15
+ p = urllib.parse.urlparse(url)
16
+ q = urllib.parse.parse_qs(p.query)["q"][0]
17
+ if p.path.endswith("search/issues"):
18
+ return {"items": [
19
+ {"title": "Add Foo MCP server", "html_url": "u/1", "comments": 2,
20
+ "repository_url": "https://api.github.com/repos/o/r", "reactions": {"total_count": 10}},
21
+ {"title": "Add Bar MCP", "html_url": "u/2", "comments": 0,
22
+ "repository_url": "https://api.github.com/repos/o/r", "reactions": {"total_count": 6}},
23
+ {"title": "Low", "html_url": "u/3", "comments": 0,
24
+ "repository_url": "https://api.github.com/repos/o/r", "reactions": {"total_count": 0}},
25
+ ]}
26
+ items = [{"full_name": "a/b", "html_url": "u", "stargazers_count": stars, "forks_count": 1,
27
+ "created_at": "2026-01-01T00:00:00Z", "pushed_at": "2026-02-01T00:00:00Z"}]
28
+ if q.startswith("foo"):
29
+ items = [{"full_name": "x/foo-mcp", "html_url": "u", "stargazers_count": 50,
30
+ "created_at": "2026-01-01T00:00:00Z", "pushed_at": "2026-02-01T00:00:00Z"}]
31
+ if q.startswith("bar"):
32
+ return {"total_count": 0, "items": []}
33
+ return {"total_count": len(items), "items": items}
34
+ return t
35
+
36
+
37
+ def test_expand_query_dates():
38
+ import datetime as dt
39
+ assert expand_query("x>{days30}", dt.date(2026, 11, 13)) == "x>2026-10-14"
40
+
41
+
42
+ def test_config_defaults_and_file(tmp_path):
43
+ f = tmp_path / "c.toml"
44
+ f.write_text('[radar]\ntop=5\n[[categories]]\nid="x"\nlabel="X"\nquery="topic:x"\n')
45
+ cfg = load_config(f)
46
+ assert cfg["radar"]["top"] == 5 and cfg["categories"][0]["id"] == "x"
47
+ with pytest.raises(FileNotFoundError):
48
+ load_config(tmp_path / "missing.toml")
49
+
50
+
51
+ def test_scan_demand_gaps():
52
+ gh = GitHub(fake_transport())
53
+ cfg = default_config()
54
+ sc = analysis.scan(gh, cfg)
55
+ assert set(sc) == {"mcp_top", "mcp_new"}
56
+ dm = analysis.demand(gh, cfg)
57
+ assert [d["reactions"] for d in dm] == [10, 6] # zero-reaction issue filtered
58
+ gp = analysis.gaps(gh, cfg, dm)
59
+ assert gp[0]["subject"] == "bar" and gp[0]["gap_score"] == 6.0 # nobody serves Bar
60
+ assert gp[1]["subject"] == "foo" and gp[1]["gap_score"] == 5.0 # one serious server halves it
61
+
62
+
63
+ def test_subject_of():
64
+ assert analysis.subject_of("Add Amazon Connect MCP server", ["add", "mcp", "server"]) == "amazon connect"
65
+
66
+
67
+ def test_star_gains(tmp_path):
68
+ cur = {"c": {"repos": [{"repo": "a/b", "stars": 120}, {"repo": "n/w", "stars": 5}]}}
69
+ analysis.save_snapshot(tmp_path, "2026-11-06", {"scan": {"c": {"repos": [{"repo": "a/b", "stars": 100}]}}})
70
+ prev = analysis.load_previous(tmp_path, "2026-11-13")
71
+ assert analysis.star_gains(cur, prev) == {"a/b": 20}
72
+ assert analysis.load_previous(tmp_path, "2026-11-06") is None
73
+
74
+
75
+ def test_cli_report_with_progression(tmp_path, monkeypatch, capsys):
76
+ monkeypatch.chdir(tmp_path)
77
+ (tmp_path / "radar-data").mkdir()
78
+ (tmp_path / "radar-data" / "snapshot_2020-01-01.json").write_text(json.dumps(
79
+ {"date": "2020-01-01", "scan": {"mcp_top": {"repos": [{"repo": "a/b", "stars": 60}]}}}))
80
+ assert cli.main(["report", "--out", "o"], transport=fake_transport()) == 0
81
+ html = (tmp_path / "o" / "report.html").read_text()
82
+ assert "Gains since 2020-01-01" in html and "+40" in html
83
+ assert "a/b" in (tmp_path / "o" / "report.csv").read_text()
84
+
85
+
86
+ def test_cli_demo_works_offline(tmp_path, monkeypatch, capsys):
87
+ monkeypatch.chdir(tmp_path)
88
+ for cmd in ("scan", "demand", "gaps"):
89
+ assert cli.main([cmd, "--demo"]) == 0
90
+ out = capsys.readouterr().out
91
+ assert "acme/demo-mcp" in out and "foodtracker" in out
92
+ assert cli.main(["gaps", "--demo", "--json"]) == 0
93
+ assert cli.main(["report", "--demo", "--out", "o"]) == 0
94
+ assert (tmp_path / "o" / "report.html").exists()
95
+
96
+
97
+ def test_cli_errors(tmp_path, monkeypatch, capsys):
98
+ monkeypatch.chdir(tmp_path)
99
+ assert cli.main(["scan", "--config", "nope.toml"]) == 2
100
+
101
+ def boom(url):
102
+ raise RuntimeError("rate limited")
103
+ assert cli.main(["scan"], transport=boom) == 1