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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: estran-mcp
3
+ Version: 0.1.0
4
+ Summary: Connect Claude, Cursor or Codex to your Estran GEO analyses
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: mcp<3,>=2.3
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pydantic>=2
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: estran-mcp
3
+ Version: 0.1.0
4
+ Summary: Connect Claude, Cursor or Codex to your Estran GEO analyses
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: mcp<3,>=2.3
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pydantic>=2
@@ -0,0 +1,9 @@
1
+ README.md
2
+ estran_mcp.py
3
+ pyproject.toml
4
+ estran_mcp.egg-info/PKG-INFO
5
+ estran_mcp.egg-info/SOURCES.txt
6
+ estran_mcp.egg-info/dependency_links.txt
7
+ estran_mcp.egg-info/entry_points.txt
8
+ estran_mcp.egg-info/requires.txt
9
+ estran_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ estran-mcp = estran_mcp:main
@@ -0,0 +1,3 @@
1
+ mcp<3,>=2.3
2
+ httpx>=0.27
3
+ pydantic>=2
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+