#!/usr/bin/env python3 """Conector de Sondeva Renovables para asistentes de IA (Model Context Protocol, por la entrada y la salida estandar). Le deja a su asistente (Claude, ChatGPT de escritorio, Cursor, o cualquiera que hable MCP) buscar proyectos y leer una ficha con SU clave de la API de datos. Corre en su ordenador; Sondeva no ve sus conversaciones: solo recibe las mismas peticiones que haria su programa. Necesita: Python 3.8 o superior. Nada mas: solo la biblioteca estandar. La clave: «Mi espacio» → «API» en https://renovables.sondeva.es (Enterprise Plus). Se le muestra una vez. Claude Code: claude mcp add sondeva --env SONDEVA_CLAVE=sdv_... -- python /ruta/a/sondeva-mcp.py Claude Desktop (claude_desktop_config.json): {"mcpServers": {"sondeva": {"command": "python", "args": ["/ruta/a/sondeva-mcp.py"], "env": {"SONDEVA_CLAVE": "sdv_..."}}}} LO QUE HACE Y LO QUE NO: solo LEE (`GET /api/v1/proyectos` y `GET /api/v1/proyectos/`). Cada proyecto que le enseña gasta del cupo mensual de su plan, como el Excel (repetir uno ya sacado ese mes no gasta). No guarda nada, no escribe ficheros y no habla con nadie mas que con renovables.sondeva.es. Ningun dato de personas fisicas: los de la API. """ import json import os import sys import urllib.error import urllib.parse import urllib.request BASE = os.environ.get("SONDEVA_URL", "https://renovables.sondeva.es").rstrip("/") VERSION = "1.0.0" #: Las versiones del protocolo que entiende; si el cliente pide otra, se le contesta con la primera. PROTOCOLOS = ("2025-06-18", "2025-03-26", "2024-11-05") ESPERA = 60 TECNOLOGIAS = ["eolica", "fotovoltaica", "evacuacion", "hibrida", "almacenamiento", "termosolar", "centro_datos", "biogas", "hidrogeno"] FASES = ["informacion_publica", "evaluacion_ambiental", "autorizacion_previa", "autorizacion_construccion", "acuerdo_ejecucion", "interes_autonomico", "utilidad_publica", "prorroga", "transmision", "otro"] ESTADOS = ["vivo", "desistido", "caducado", "denegado"] _LISTA = {"type": "array", "items": {"type": "string"}} HERRAMIENTAS = [ { "name": "buscar_proyectos", "description": ( "Busca proyectos renovables en España en los boletines oficiales (BOE y boletines autonómicos) con los filtros del buscador " "de Sondeva. Devuelve los que encajan, del más nuevo al más viejo, con su fase administrativa, estado, potencia, promotor, " "CIF, provincias, nudo de evacuación, equipos declarados y el enlace al anuncio oficial. Cada proyecto devuelto gasta del " "cupo mensual de la cuenta (repetir uno ya sacado ese mes no gasta): pida pocas páginas y filtre bien. «total» es todo lo " "que encaja; «paginas», cuántas hay."), "inputSchema": { "type": "object", "properties": { "tecnologias": {"type": "array", "items": {"type": "string", "enum": TECNOLOGIAS}}, "fases": {"type": "array", "items": {"type": "string", "enum": FASES}}, "estados": {"type": "array", "items": {"type": "string", "enum": ESTADOS}, "description": "Vacío o sin poner: todos. Lo habitual es [\"vivo\"]."}, "ccaa": dict(_LISTA, description="Comunidades autónomas, como las escribe el buscador: «Aragón», «Castilla y León»."), "provincias": dict(_LISTA, description="Provincias: «Soria», «Zaragoza»."), "fuentes": dict(_LISTA, description="Boletines: «BOE», «BOA», «BOJA»…"), "mw_min": {"type": "number"}, "mw_max": {"type": "number"}, "dias": {"type": ["integer", "null"], "description": "Los últimos N días. null o sin poner: todo el histórico."}, "texto": {"type": "string", "description": "Palabras que tienen que estar todas: nombre, promotor, provincia, nudo, " "identificador o marca de equipo («Nordex»)."}, "pagina": {"type": "integer", "minimum": 1, "default": 1}, "por_pagina": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}, }, "additionalProperties": False, }, }, { "name": "ver_proyecto", "description": "Un proyecto por su id de Sondeva («BOE:BOE-B-2026-12345», el campo «id» de buscar_proyectos), con todos sus campos.", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"], "additionalProperties": False}, }, ] _FILTROS = ("tecnologias", "fases", "estados", "ccaa", "provincias", "fuentes", "mw_min", "mw_max", "dias", "texto") class FalloDeLaApi(Exception): pass def _pedir(ruta, consulta=None): clave = (os.environ.get("SONDEVA_CLAVE") or "").strip() if not clave: raise FalloDeLaApi("Falta la clave: ponga SONDEVA_CLAVE=sdv_… en la configuración del conector (Mi espacio → API).") url = BASE + ruta + ("?" + urllib.parse.urlencode(consulta) if consulta else "") pedido = urllib.request.Request(url, headers={"Authorization": "Bearer " + clave, "Accept": "application/json", "User-Agent": "sondeva-mcp/" + VERSION}) try: with urllib.request.urlopen(pedido, timeout=ESPERA) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: try: mensaje = json.loads(e.read().decode("utf-8")).get("error") or "" except Exception: mensaje = "" espera = e.headers.get("Retry-After") if e.code == 429 else None raise FalloDeLaApi("La API contestó " + str(e.code) + (": " + mensaje if mensaje else "") + (" (vuelva a intentarlo en " + espera + " s)" if espera else "")) from None except (urllib.error.URLError, TimeoutError, ValueError) as e: raise FalloDeLaApi("No se pudo hablar con " + BASE + ": " + str(e)) from None def buscar_proyectos(args): f = {k: args[k] for k in _FILTROS if k in args} f.setdefault("dias", None) consulta = {"f": json.dumps(f, ensure_ascii=False), "pagina": int(args.get("pagina") or 1), "por_pagina": int(args.get("por_pagina") or 20)} return _pedir("/api/v1/proyectos", consulta) def ver_proyecto(args): ident = str(args.get("id") or "").strip() if not ident: raise FalloDeLaApi("Falta el id del proyecto.") return _pedir("/api/v1/proyectos/" + urllib.parse.quote(ident, safe="")) LLAMADAS = {"buscar_proyectos": buscar_proyectos, "ver_proyecto": ver_proyecto} def contestar(mensaje): """La respuesta a un mensaje JSON-RPC, o None si es una notificacion (no lleva id) o no se contesta.""" metodo, ident = mensaje.get("method"), mensaje.get("id") if ident is None: return None parametros = mensaje.get("params") or {} if metodo == "initialize": pedido = parametros.get("protocolVersion") resultado = {"protocolVersion": pedido if pedido in PROTOCOLOS else PROTOCOLOS[0], "capabilities": {"tools": {}}, "serverInfo": {"name": "sondeva-renovables", "version": VERSION}, "instructions": "Datos de proyectos renovables de España de los boletines oficiales. Cite siempre el enlace " "al anuncio oficial de cada proyecto. Cada proyecto devuelto gasta del cupo mensual del usuario."} elif metodo == "ping": resultado = {} elif metodo == "tools/list": resultado = {"tools": HERRAMIENTAS} elif metodo == "tools/call": nombre, args = parametros.get("name"), parametros.get("arguments") or {} if nombre not in LLAMADAS: return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32602, "message": "No hay ninguna herramienta «" + str(nombre) + "»."}} try: datos = LLAMADAS[nombre](args) resultado = {"content": [{"type": "text", "text": json.dumps(datos, ensure_ascii=False)}], "isError": False} except FalloDeLaApi as e: resultado = {"content": [{"type": "text", "text": str(e)}], "isError": True} else: return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32601, "message": "Método desconocido: " + str(metodo)}} return {"jsonrpc": "2.0", "id": ident, "result": resultado} def main(entrada=None, salida=None): entrada = entrada or sys.stdin.buffer salida = salida or sys.stdout.buffer for linea in entrada: linea = linea.strip() if not linea: continue try: mensaje = json.loads(linea.decode("utf-8")) except ValueError: respuesta = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "No es JSON."}} else: respuesta = contestar(mensaje) if isinstance(mensaje, dict) else None if respuesta is not None: salida.write((json.dumps(respuesta, ensure_ascii=False) + "\n").encode("utf-8")) salida.flush() if __name__ == "__main__": main()