estran-mcp 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.
- estran_mcp-0.1.0/PKG-INFO +8 -0
- estran_mcp-0.1.0/README.md +35 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/PKG-INFO +8 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/SOURCES.txt +9 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/dependency_links.txt +1 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/entry_points.txt +2 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/requires.txt +3 -0
- estran_mcp-0.1.0/estran_mcp.egg-info/top_level.txt +1 -0
- estran_mcp-0.1.0/estran_mcp.py +189 -0
- estran_mcp-0.1.0/pyproject.toml +16 -0
- estran_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# estran-mcp
|
|
2
|
+
|
|
3
|
+
Connect Claude Code, Claude Desktop, Cursor or Codex to your Estran GEO analyses.
|
|
4
|
+
On first use your browser opens the Estran login, you click **Allow**, and that's it.
|
|
5
|
+
The token is kept in `~/.estran/credentials.json` and refreshed automatically.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Claude Code:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add estran -- uvx estran-mcp
|
|
13
|
+
# before the PyPI release, from this repo:
|
|
14
|
+
claude mcp add estran -- uvx --from /path/to/estran_mcp estran-mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Claude Desktop / Cursor (`mcpServers` config):
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{ "estran": { "command": "uvx", "args": ["estran-mcp"] } }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Tool | What it returns |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `list_analyses` | Category analyses, grouped by audited site (`audit_domain`) |
|
|
28
|
+
| `get_recommendations(benchmark_id)` | GEO action plan of a category |
|
|
29
|
+
| `get_analysis_data(benchmark_id)` | Visibility scores, cited sources, gaps vs competitors |
|
|
30
|
+
| `get_raw_answers(benchmark_id, offset, limit, llm)` | Raw AI answers, paginated |
|
|
31
|
+
| `logout` | Deletes the local token |
|
|
32
|
+
|
|
33
|
+
## Local dev
|
|
34
|
+
|
|
35
|
+
`ESTRAN_API_URL=http://localhost:8000 ESTRAN_DASHBOARD_URL=http://localhost:3002 uvx --from . estran-mcp`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
estran_mcp
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Estran MCP server: exposes the user's GEO analyses to Claude, Cursor, Codex...
|
|
2
|
+
|
|
3
|
+
Runs locally (stdio). On first use it opens dashboard.estran.io/connect in the browser,
|
|
4
|
+
the user signs in, and the page posts the Firebase refresh token back to a loopback
|
|
5
|
+
port. Calls then go to the existing dashboard API with a Firebase ID token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
from urllib.parse import parse_qs
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from mcp.server.mcpserver import MCPServer
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
API_URL = os.getenv("ESTRAN_API_URL", "https://api.estran.io/dashboard").rstrip("/")
|
|
23
|
+
DASHBOARD_URL = os.getenv("ESTRAN_DASHBOARD_URL", "https://dashboard.estran.io").rstrip("/")
|
|
24
|
+
# Public Firebase web key (same one the dashboard ships to every browser).
|
|
25
|
+
FIREBASE_API_KEY = os.getenv("ESTRAN_FIREBASE_API_KEY", "AIzaSyDgwJt3K0gDaZcU7KJOMD2AGD0moyijBVw")
|
|
26
|
+
CREDS_PATH = Path(os.getenv("ESTRAN_HOME", Path.home() / ".estran")) / "credentials.json"
|
|
27
|
+
LOGIN_TIMEOUT_S = 300
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Credentials(BaseModel):
|
|
31
|
+
refresh_token: str
|
|
32
|
+
id_token: str = ""
|
|
33
|
+
expires_at: float = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- Auth -------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def _save(creds: Credentials) -> None:
|
|
39
|
+
CREDS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
CREDS_PATH.write_text(creds.model_dump_json())
|
|
41
|
+
CREDS_PATH.chmod(0o600)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load() -> Optional[Credentials]:
|
|
45
|
+
try:
|
|
46
|
+
return Credentials.model_validate_json(CREDS_PATH.read_text())
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _browser_login() -> Credentials:
|
|
52
|
+
"""Open the dashboard /connect page and wait for it to POST the refresh token back."""
|
|
53
|
+
state = secrets.token_urlsafe(32)
|
|
54
|
+
received: dict[str, str] = {}
|
|
55
|
+
done = threading.Event()
|
|
56
|
+
|
|
57
|
+
class Handler(BaseHTTPRequestHandler):
|
|
58
|
+
def do_POST(self) -> None:
|
|
59
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
60
|
+
form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()}
|
|
61
|
+
ok = self.path == "/callback" and secrets.compare_digest(form.get("state", ""), state) and form.get("refresh_token")
|
|
62
|
+
if ok:
|
|
63
|
+
received.update(form)
|
|
64
|
+
done.set()
|
|
65
|
+
self.send_response(200 if ok else 400)
|
|
66
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
67
|
+
self.end_headers()
|
|
68
|
+
msg = "Estran est connecté. Vous pouvez fermer cet onglet. / Estran is connected, you can close this tab." if ok else "Invalid request."
|
|
69
|
+
self.wfile.write(f"<html><body style='font-family:sans-serif;padding:3rem'>{msg}</body></html>".encode())
|
|
70
|
+
|
|
71
|
+
def log_message(self, format: str, *args: Any) -> None: # stdout is the MCP channel: stay silent
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
75
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
76
|
+
try:
|
|
77
|
+
webbrowser.open(f"{DASHBOARD_URL}/connect?port={server.server_port}&state={state}")
|
|
78
|
+
if not done.wait(LOGIN_TIMEOUT_S):
|
|
79
|
+
raise RuntimeError("Connexion Estran non terminée dans le navigateur (5 min). Relancez l'outil.")
|
|
80
|
+
finally:
|
|
81
|
+
server.shutdown()
|
|
82
|
+
creds = Credentials(refresh_token=received["refresh_token"])
|
|
83
|
+
_save(creds)
|
|
84
|
+
return creds
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _refresh(creds: Credentials) -> Optional[Credentials]:
|
|
88
|
+
r = httpx.post(
|
|
89
|
+
f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_API_KEY}",
|
|
90
|
+
data={"grant_type": "refresh_token", "refresh_token": creds.refresh_token},
|
|
91
|
+
timeout=15,
|
|
92
|
+
)
|
|
93
|
+
if r.status_code != 200:
|
|
94
|
+
return None # revoked / expired → caller falls back to browser login
|
|
95
|
+
d = r.json()
|
|
96
|
+
creds = Credentials(
|
|
97
|
+
refresh_token=d["refresh_token"],
|
|
98
|
+
id_token=d["id_token"],
|
|
99
|
+
expires_at=time.time() + int(d["expires_in"]) - 60,
|
|
100
|
+
)
|
|
101
|
+
_save(creds)
|
|
102
|
+
return creds
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _id_token(force_refresh: bool = False) -> str:
|
|
106
|
+
creds = _load()
|
|
107
|
+
if creds and creds.id_token and creds.expires_at > time.time() and not force_refresh:
|
|
108
|
+
return creds.id_token
|
|
109
|
+
refreshed = _refresh(creds) if creds else None
|
|
110
|
+
if not refreshed:
|
|
111
|
+
refreshed = _refresh(_browser_login())
|
|
112
|
+
if not refreshed:
|
|
113
|
+
raise RuntimeError("Impossible d'obtenir un token Estran après connexion.")
|
|
114
|
+
return refreshed.id_token
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get(path: str, **params: Any) -> Any:
|
|
118
|
+
def call(force_refresh: bool) -> httpx.Response:
|
|
119
|
+
headers = {"Authorization": f"Bearer {_id_token(force_refresh)}"}
|
|
120
|
+
return httpx.get(f"{API_URL}{path}", params=params, headers=headers, timeout=60)
|
|
121
|
+
|
|
122
|
+
r = call(False)
|
|
123
|
+
if r.status_code == 401:
|
|
124
|
+
r = call(True)
|
|
125
|
+
if r.status_code >= 400:
|
|
126
|
+
raise RuntimeError(f"Estran API {r.status_code}: {r.text[:300]}")
|
|
127
|
+
return r.json()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- Tools ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
mcp = MCPServer(
|
|
133
|
+
"estran",
|
|
134
|
+
instructions=(
|
|
135
|
+
"Estran mesure la visibilité d'une marque dans les réponses des IA (GEO). "
|
|
136
|
+
"Commencez par list_analyses, puis get_recommendations sur une catégorie. "
|
|
137
|
+
"N'utilisez get_analysis_data et get_raw_answers que pour justifier ou creuser une reco."
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
def list_analyses() -> list[dict[str, Any]]:
|
|
144
|
+
"""Liste les analyses par catégorie du compte (les plus récentes d'abord).
|
|
145
|
+
`audit_domain` indique le site audité dont la catégorie fait partie (null = analyse autonome)."""
|
|
146
|
+
return _get("/referencing/history_records")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@mcp.tool()
|
|
150
|
+
def get_recommendations(benchmark_id: int) -> dict[str, Any]:
|
|
151
|
+
"""Plan d'action GEO d'une catégorie : recommandations priorisées, sources à travailler, concurrents."""
|
|
152
|
+
b = _get(f"/referencing/benchmark/{benchmark_id}")
|
|
153
|
+
keys = ("id", "status", "category_name", "country", "language", "brief", "created_at", "geo_action_plan")
|
|
154
|
+
return {k: b.get(k) for k in keys}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@mcp.tool()
|
|
158
|
+
def get_analysis_data(benchmark_id: int) -> dict[str, Any]:
|
|
159
|
+
"""Données agrégées d'une catégorie : scores de visibilité, sources citées par les IA, gaps vs concurrents."""
|
|
160
|
+
b = _get(f"/referencing/benchmark/{benchmark_id}")
|
|
161
|
+
keys = ("id", "category_name", "results", "aggregated_sources", "source_types")
|
|
162
|
+
return {k: b.get(k) for k in keys}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@mcp.tool()
|
|
166
|
+
def get_raw_answers(benchmark_id: int, offset: int = 0, limit: int = 20, llm: Optional[str] = None) -> dict[str, Any]:
|
|
167
|
+
"""Réponses brutes des IA (prompt, réponse, sources citées, marques mentionnées), paginées.
|
|
168
|
+
Filtre optionnel par `llm` (ex. "openai", "gemini", "perplexity")."""
|
|
169
|
+
# ponytail: the API returns every prompt, we page client-side; add server paging if payloads hurt.
|
|
170
|
+
prompts = _get(f"/referencing/benchmark/{benchmark_id}", include_prompts="true").get("prompts") or []
|
|
171
|
+
if llm:
|
|
172
|
+
prompts = [p for p in prompts if llm.lower() in (p.get("provider") or "").lower()]
|
|
173
|
+
limit = max(1, min(limit, 50))
|
|
174
|
+
return {"total": len(prompts), "offset": offset, "items": prompts[offset:offset + limit]}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool()
|
|
178
|
+
def logout() -> str:
|
|
179
|
+
"""Déconnecte Estran sur cette machine (supprime le token local)."""
|
|
180
|
+
CREDS_PATH.unlink(missing_ok=True)
|
|
181
|
+
return "Déconnecté."
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main() -> None:
|
|
185
|
+
mcp.run()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "estran-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Connect Claude, Cursor or Codex to your Estran GEO analyses"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = ["mcp>=2.3,<3", "httpx>=0.27", "pydantic>=2"]
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
estran-mcp = "estran_mcp:main"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["setuptools>=68"]
|
|
13
|
+
build-backend = "setuptools.build_meta"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
py-modules = ["estran_mcp"]
|