"""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""" **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. """ 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"Clients SSE uniquement : `{MCP_SSE_URL}`") 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)