Spaces:
Running
Running
Download app.py from Ekimetrics/MCP_indicators: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/Ekimetrics/MCP_indicators/resolve/refs%2Fpr%2F2/app.py
- Command line
-
hf download hf://spaces/Ekimetrics/MCP_indicators@refs/pr/2/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Ekimetrics/MCP_indicators/resolve/refs%2Fpr%2F2/app.py
15.2 kB
| """Serveur MCP « Indicateurs territoriaux de transition écologique » — Gradio / Hugging Face Spaces. | |
| Les outils MCP sont déclarés avec gr.api() : leur schéma vient des signatures Python et | |
| des docstrings, indépendamment des composants de l'interface. Gradio ≥ 6 expose les deux | |
| transports MCP (Streamable HTTP sur /gradio_api/mcp/ et SSE sur /gradio_api/mcp/sse). | |
| L'interface web sert de vitrine et de banc d'essai ; ses événements sont privés. | |
| Outils : search_indicators, list_indicators, get_indicator_details, query_indicator_data, | |
| find_territory. | |
| Configuration : INDICATEURS_TE_TOKEN (secret du Space), voir .env.example. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import gradio as gr | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from src.models import GEOGRAPHIC_LEVELS # noqa: E402 | |
| from src.tools import ( # noqa: E402 | |
| find_territory, | |
| get_indicator_details, | |
| list_indicators, | |
| query_indicator_data, | |
| search_indicators, | |
| ) | |
| if not os.getenv("INDICATEURS_TE_TOKEN"): | |
| print("WARNING: INDICATEURS_TE_TOKEN not set. API calls will fail.") | |
| SPACE_HOST = os.getenv("SPACE_HOST") # renseigné par Hugging Face Spaces | |
| def _is_hf_space() -> bool: | |
| return os.getenv("SYSTEM") == "spaces" or os.getenv("SPACE_ID") is not None | |
| def _base_url() -> str: | |
| if SPACE_HOST: | |
| return f"https://{SPACE_HOST}" | |
| return f"http://127.0.0.1:{os.environ.get('GRADIO_SERVER_PORT', '7860')}" | |
| MCP_URL = f"{_base_url()}/gradio_api/mcp/" | |
| MCP_SSE_URL = f"{MCP_URL}sse" | |
| LEVEL_CHOICES = [("Région", "region"), ("Département", "departement"), ("EPCI", "epci"), ("Commune", "commune")] | |
| LEVEL_CHOICES_ALL = [("Tous niveaux", "")] + LEVEL_CHOICES | |
| ACCUEIL = """ | |
| # 📊 Indicateurs territoriaux de transition écologique — serveur MCP | |
| **Interrogez les indicateurs environnementaux des territoires français (régions, départements, | |
| EPCI, communes) directement depuis votre assistant IA.** | |
| Ce serveur [MCP](https://modelcontextprotocol.io) donne à Claude, ChatGPT, Cursor ou tout autre | |
| client compatible l'accès aux ~170 indicateurs du | |
| [Hub d'indicateurs territoriaux de la transition écologique](https://ecologie.data.gouv.fr/indicators) | |
| (CGDD · Ministère de la Transition écologique) : émissions de gaz à effet de serre, consommation | |
| d'espaces, agriculture bio, mobilité, logement… avec les valeurs par territoire et par année, | |
| les définitions, les méthodes de calcul et les sources. | |
| Testez les outils ci-dessous, ou connectez le serveur à votre assistant en une minute. | |
| """ | |
| CONNEXION = """ | |
| ### 🔌 Connecter à votre assistant | |
| Même URL pour tous les clients (transport *Streamable HTTP*, sans authentification côté client) : | |
| """ | |
| PROMPTS = """ | |
| ### 💬 À essayer une fois connecté | |
| - « Quelle est l'évolution de la consommation d'espaces naturels en PACA depuis 2015 ? » | |
| - « Compare les émissions de gaz à effet de serre par habitant des départements bretons. » | |
| - « Quelle part de l'agriculture bio dans la Métropole de Lyon ? » | |
| - « Quels indicateurs de mobilité existent à l'échelle communale ? » | |
| """ | |
| PIED = f""" | |
| <small> | |
| **Outils MCP** : `search_indicators` · `list_indicators` · `get_indicator_details` · | |
| `query_indicator_data` · `find_territory` — schéma : [{MCP_URL}schema]({MCP_URL}schema) | |
| Démonstration réalisée par [Ekimetrics](https://ekimetrics.com). Données : Hub d'indicateurs | |
| territoriaux de la transition écologique (CGDD / Ministère de la Transition écologique), API | |
| `api.indicateurs.ecologie.gouv.fr`. Les valeurs sont restituées telles que publiées par le Hub ; | |
| consultez les métadonnées (`get_indicator_details`) pour la méthode de calcul, la couverture et | |
| les sources avant toute interprétation. | |
| </small> | |
| """ | |
| SNIPPET_CLAUDE_CODE = f"claude mcp add --transport http indicateurs-te {MCP_URL}" | |
| SNIPPET_JSON = f"""{{ | |
| "mcpServers": {{ | |
| "indicateurs-te": {{ | |
| "url": "{MCP_URL}" | |
| }} | |
| }} | |
| }}""" | |
| SNIPPET_PYTHON = f"""from mcp import ClientSession | |
| from mcp.client.streamable_http import streamablehttp_client | |
| async with streamablehttp_client("{MCP_URL}") as (read, write, _): | |
| async with ClientSession(read, write) as session: | |
| await session.initialize() | |
| result = await session.call_tool( | |
| "search_indicators", {{"query": "consommation espace"}} | |
| ) | |
| print(result.content[0].text)""" | |
| # --------------------------------------------------------------------------- UI helpers | |
| _LIST_COLUMNS = ["id", "libelle", "unite", "mailles", "thematique"] | |
| _LIST_WIDTHS = ["7%", "43%", "10%", "22%", "18%"] | |
| def _indicators_table(payload: dict) -> list[list]: | |
| return [ | |
| [i.get("id"), i.get("libelle"), i.get("unite"), ", ".join(i.get("mailles") or []), i.get("thematique")] | |
| for i in payload.get("indicators") or [] | |
| ] | |
| def _status(payload: dict, ok: str) -> str: | |
| if "error" in payload: | |
| msg = f"⚠️ **{payload['error']}**" | |
| if payload.get("hint"): | |
| msg += f" \n_{payload['hint']}_" | |
| return msg | |
| if payload.get("hint"): | |
| return f"{ok} \nℹ️ {payload['hint']}" | |
| return ok | |
| async def _ui_search(query: str): | |
| raw = await search_indicators(query) | |
| payload = json.loads(raw) | |
| status = _status(payload, f"**{payload.get('total_count', 0)} indicateur(s)**" + (" (30 premiers)" if payload.get("truncated") else "")) | |
| return status, _indicators_table(payload), raw | |
| async def _ui_list(thematique: str, maille: str): | |
| raw = await list_indicators(thematique, maille) | |
| payload = json.loads(raw) | |
| status = _status(payload, f"**{payload.get('count', 0)} indicateur(s)**") | |
| return status, _indicators_table(payload), raw | |
| async def _ui_details(indicator_id: str): | |
| raw = await get_indicator_details(indicator_id) | |
| payload = json.loads(raw) | |
| if "error" in payload: | |
| return _status(payload, ""), None | |
| m = payload["metadata"] | |
| lines = [ | |
| f"### {m.get('libelle')} (id {m.get('id')})", | |
| f"**Unité :** {m.get('unite') or '—'} · **Thème :** {m.get('thematique') or '—'} · " | |
| f"**Couverture :** {m.get('couverture_geographique') or '—'}", | |
| f"**Mailles :** {', '.join(m.get('mailles_disponibles') or [])} · " | |
| f"**Années :** {', '.join(m.get('annees_disponibles') or []) or '—'}", | |
| ] | |
| if m.get("description"): | |
| lines.append(m["description"]) | |
| if m.get("methode_calcul"): | |
| lines.append(f"**Méthode de calcul :** {m['methode_calcul']}") | |
| if payload.get("sources"): | |
| src = "; ".join( | |
| f"{s.get('producteur_source') or s.get('nom_source')}" + (f" ([lien]({s['lien_page']}))" if s.get("lien_page") else "") | |
| for s in payload["sources"] | |
| ) | |
| lines.append(f"**Sources :** {src}") | |
| if payload.get("sources_warning"): | |
| lines.append(f"ℹ️ {payload['sources_warning']}") | |
| return "\n\n".join(lines), payload | |
| async def _ui_query(indicator_id: str, level: str, code: str, year: str, limit: float): | |
| raw = await query_indicator_data(indicator_id, level, code, year, int(limit)) | |
| payload = json.loads(raw) | |
| if "error" in payload: | |
| return _status(payload, ""), [], raw | |
| unite = payload.get("unite") or "" | |
| status = _status( | |
| payload, | |
| f"**{payload.get('indicator_name')}** — {payload.get('total_count', 0)} ligne(s)" | |
| + (" (tronqué)" if payload.get("truncated") else "") | |
| + (f" · unité : {unite}" if unite else ""), | |
| ) | |
| rows = [[d.get("geocode"), d.get("libelle"), d.get("annee"), d.get("valeur")] for d in payload.get("data") or []] | |
| return status, rows, raw | |
| async def _ui_territory(name: str, level: str): | |
| raw = await find_territory(name, level) | |
| payload = json.loads(raw) | |
| status = _status(payload, f"**{payload.get('total_count', 0)} territoire(s)**") | |
| rows = [[t.get("geographic_level"), t.get("geocode"), t.get("libelle")] for t in payload.get("territories") or []] | |
| return status, rows, raw | |
| # --------------------------------------------------------------------------- app | |
| with gr.Blocks(title="Indicateurs TE — serveur MCP") as demo: | |
| # Outils exposés aux assistants IA (schéma MCP dérivé des docstrings). | |
| gr.api(search_indicators, api_name="search_indicators") | |
| gr.api(list_indicators, api_name="list_indicators") | |
| gr.api(get_indicator_details, api_name="get_indicator_details") | |
| gr.api(query_indicator_data, api_name="query_indicator_data") | |
| gr.api(find_territory, api_name="find_territory") | |
| gr.Markdown(ACCUEIL) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=3, min_width=360): | |
| with gr.Tab("🔎 Rechercher"): | |
| q = gr.Textbox(label="Mots-clés", placeholder="consommation espace, émissions gaz effet de serre, surface bio…") | |
| btn_q = gr.Button("Rechercher", variant="primary") | |
| st_q = gr.Markdown() | |
| tbl_q = gr.Dataframe(headers=_LIST_COLUMNS, interactive=False, wrap=True, column_widths=_LIST_WIDTHS, label="Indicateurs") | |
| with gr.Accordion("Réponse JSON (ce que reçoit l'assistant)", open=False): | |
| js_q = gr.Code(language="json", label="search_indicators") | |
| gr.Examples( | |
| examples=[["consommation espace"], ["émissions gaz effet de serre"], ["surface bio"], ["stationnement vélo"], ["logements vacants"]], | |
| inputs=[q], outputs=[st_q, tbl_q, js_q], fn=_ui_search, run_on_click=True, | |
| cache_examples=False, api_visibility="private", label="Exemples", | |
| ) | |
| btn_q.click(_ui_search, inputs=q, outputs=[st_q, tbl_q, js_q], api_visibility="private") | |
| q.submit(_ui_search, inputs=q, outputs=[st_q, tbl_q, js_q], api_visibility="private") | |
| with gr.Tab("📚 Catalogue"): | |
| with gr.Row(): | |
| them = gr.Textbox(label="Thématique France Nation Verte", placeholder="déplacer, loger, produire, nourrir, consommer, écosystèmes…") | |
| maille = gr.Dropdown(label="Disponible à la maille", choices=LEVEL_CHOICES_ALL, value="") | |
| btn_l = gr.Button("Lister", variant="primary") | |
| st_l = gr.Markdown() | |
| tbl_l = gr.Dataframe(headers=_LIST_COLUMNS, interactive=False, wrap=True, column_widths=_LIST_WIDTHS, label="Indicateurs") | |
| with gr.Accordion("Réponse JSON (ce que reçoit l'assistant)", open=False): | |
| js_l = gr.Code(language="json", label="list_indicators") | |
| btn_l.click(_ui_list, inputs=[them, maille], outputs=[st_l, tbl_l, js_l], api_visibility="private") | |
| with gr.Tab("📄 Détails"): | |
| ind = gr.Textbox(label="ID de l'indicateur", placeholder="611") | |
| btn_d = gr.Button("Voir les détails", variant="primary") | |
| md_d = gr.Markdown() | |
| with gr.Accordion("Réponse JSON (ce que reçoit l'assistant)", open=False): | |
| js_d = gr.JSON(label="get_indicator_details") | |
| btn_d.click(_ui_details, inputs=ind, outputs=[md_d, js_d], api_visibility="private") | |
| ind.submit(_ui_details, inputs=ind, outputs=[md_d, js_d], api_visibility="private") | |
| with gr.Tab("📈 Données"): | |
| with gr.Row(): | |
| ind2 = gr.Textbox(label="ID de l'indicateur", placeholder="611") | |
| lvl = gr.Dropdown(label="Niveau géographique", choices=LEVEL_CHOICES, value="region") | |
| with gr.Row(): | |
| code = gr.Textbox(label="Code du territoire (optionnel)", placeholder="93 (PACA), 13, 200046977, 75056…") | |
| year = gr.Textbox(label="Année (optionnel)", placeholder="2022") | |
| limit = gr.Slider(label="Lignes max", minimum=10, maximum=500, step=10, value=100) | |
| btn_x = gr.Button("Interroger", variant="primary") | |
| st_x = gr.Markdown() | |
| tbl_x = gr.Dataframe(headers=["geocode", "libelle", "annee", "valeur"], interactive=False, label="Valeurs") | |
| with gr.Accordion("Réponse JSON (ce que reçoit l'assistant)", open=False): | |
| js_x = gr.Code(language="json", label="query_indicator_data") | |
| gr.Examples( | |
| examples=[["611", "region", "93", "", 100], ["611", "departement", "", "2022", 100], ["23", "region", "", "2022", 100]], | |
| inputs=[ind2, lvl, code, year, limit], outputs=[st_x, tbl_x, js_x], fn=_ui_query, | |
| run_on_click=True, cache_examples=False, api_visibility="private", label="Exemples", | |
| ) | |
| btn_x.click(_ui_query, inputs=[ind2, lvl, code, year, limit], outputs=[st_x, tbl_x, js_x], api_visibility="private") | |
| with gr.Tab("🗺️ Territoire"): | |
| with gr.Row(): | |
| name = gr.Textbox(label="Nom (ou fragment)", placeholder="Lyon, Bouches-du-Rhône, Grand Paris…") | |
| lvl2 = gr.Dropdown(label="Niveau", choices=LEVEL_CHOICES_ALL, value="") | |
| btn_t = gr.Button("Trouver le code", variant="primary") | |
| st_t = gr.Markdown() | |
| tbl_t = gr.Dataframe(headers=["niveau", "geocode", "libelle"], interactive=False, label="Territoires") | |
| with gr.Accordion("Réponse JSON (ce que reçoit l'assistant)", open=False): | |
| js_t = gr.Code(language="json", label="find_territory") | |
| btn_t.click(_ui_territory, inputs=[name, lvl2], outputs=[st_t, tbl_t, js_t], api_visibility="private") | |
| name.submit(_ui_territory, inputs=[name, lvl2], outputs=[st_t, tbl_t, js_t], api_visibility="private") | |
| with gr.Column(scale=2, min_width=300): | |
| gr.Markdown(CONNEXION) | |
| gr.Textbox(value=MCP_URL, label="URL du serveur MCP", interactive=False, buttons=["copy"]) | |
| with gr.Accordion("Claude Code (terminal)", open=True): | |
| gr.Code(value=SNIPPET_CLAUDE_CODE, language="shell", label="commande") | |
| with gr.Accordion("Claude.ai / Claude Desktop", open=False): | |
| gr.Markdown( | |
| "Paramètres → **Connecteurs** → *Ajouter un connecteur personnalisé* → " | |
| "collez l'URL ci-dessus. Aucune authentification requise." | |
| ) | |
| with gr.Accordion("Cursor, Windsurf, VS Code… (mcp.json)", open=False): | |
| gr.Code(value=SNIPPET_JSON, language="json", label="mcp.json") | |
| with gr.Accordion("Python (SDK mcp)", open=False): | |
| gr.Code(value=SNIPPET_PYTHON, language="python", label="client") | |
| gr.Markdown(f"<small>Clients SSE uniquement : `{MCP_SSE_URL}`</small>") | |
| gr.Markdown(PROMPTS) | |
| gr.Markdown(PIED) | |
| if __name__ == "__main__": | |
| launch_kwargs: dict = { | |
| "mcp_server": True, | |
| "theme": gr.themes.Soft(primary_hue="blue"), | |
| "server_name": "0.0.0.0" if _is_hf_space() else "127.0.0.1", | |
| "server_port": int(os.environ.get("GRADIO_SERVER_PORT", "7860")), | |
| } | |
| demo.launch(**launch_kwargs) | |