MCP_indicators / app.py
Qdonnars's picture
Gradio 6 (transport MCP Streamable HTTP + SSE), sorties JSON compactes, sources réparées, find_territory, page Space
febc39b
Raw History Blame
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)