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.
- mcp_demand_radar-0.1.0/.gitignore +44 -0
- mcp_demand_radar-0.1.0/LICENSE +21 -0
- mcp_demand_radar-0.1.0/PKG-INFO +77 -0
- mcp_demand_radar-0.1.0/README.md +63 -0
- mcp_demand_radar-0.1.0/pyproject.toml +33 -0
- mcp_demand_radar-0.1.0/radar.example.toml +26 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/__init__.py +3 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/analysis.py +83 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/cli.py +94 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/config.py +49 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/demo.py +32 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/github.py +68 -0
- mcp_demand_radar-0.1.0/src/mcp_radar/report.py +55 -0
- mcp_demand_radar-0.1.0/tests/test_radar.py +103 -0
|
@@ -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,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
|