mcp-inegi 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.
- mcp_inegi-0.1.0/LICENSE +21 -0
- mcp_inegi-0.1.0/PKG-INFO +129 -0
- mcp_inegi-0.1.0/README.md +96 -0
- mcp_inegi-0.1.0/mcp_inegi/__init__.py +5 -0
- mcp_inegi-0.1.0/mcp_inegi/areas.py +170 -0
- mcp_inegi-0.1.0/mcp_inegi/cli.py +157 -0
- mcp_inegi-0.1.0/mcp_inegi/client.py +348 -0
- mcp_inegi-0.1.0/mcp_inegi/constants.py +196 -0
- mcp_inegi-0.1.0/mcp_inegi/data/municipios.json +2480 -0
- mcp_inegi-0.1.0/mcp_inegi/server.py +183 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/PKG-INFO +129 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/SOURCES.txt +20 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/dependency_links.txt +1 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/entry_points.txt +2 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/requires.txt +9 -0
- mcp_inegi-0.1.0/mcp_inegi.egg-info/top_level.txt +1 -0
- mcp_inegi-0.1.0/pyproject.toml +76 -0
- mcp_inegi-0.1.0/setup.cfg +4 -0
- mcp_inegi-0.1.0/tests/test_areas.py +60 -0
- mcp_inegi-0.1.0/tests/test_client.py +146 -0
- mcp_inegi-0.1.0/tests/test_constants.py +46 -0
- mcp_inegi-0.1.0/tests/test_server.py +105 -0
mcp_inegi-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inaki Sobera (nanisadw3)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mcp_inegi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-inegi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Servidor MCP para consultar indicadores oficiales del INEGI: población, inflación (INPC), empleo, PIB e IGAE, a nivel nacional, estatal y municipal
|
|
5
|
+
Author-email: Inaki Sobera <inakisobera8@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/itzamna-mx/mcp-inegi
|
|
8
|
+
Project-URL: Repository, https://github.com/itzamna-mx/mcp-inegi
|
|
9
|
+
Project-URL: Issues, https://github.com/itzamna-mx/mcp-inegi/issues
|
|
10
|
+
Keywords: mcp,mcp-server,inegi,mexico,datos-abiertos,inflacion,inpc,pib,censo,ai-agents,claude-desktop,itzamna
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: mcp>=1.2.0
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: pydantic>=2.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# mcp-inegi
|
|
35
|
+
|
|
36
|
+
Servidor MCP para que cualquier asistente de IA (Claude, ChatGPT, Gemini, Cursor o un modelo local)
|
|
37
|
+
consulte **indicadores oficiales del INEGI** y conteste citando la fuente: población, inflación,
|
|
38
|
+
empleo, PIB e IGAE, a nivel nacional, estatal y, para datos censales, municipal.
|
|
39
|
+
|
|
40
|
+
Forma parte de [Itzamná](https://inakisobera.me/itzamna/), una familia de servidores MCP de código
|
|
41
|
+
abierto sobre los datos públicos de México. Proyecto independiente, sin afiliación con el INEGI.
|
|
42
|
+
|
|
43
|
+
## Qué puedes preguntarle
|
|
44
|
+
|
|
45
|
+
- *¿Cuál fue la inflación anual de septiembre?* → 3.45 % (INPC, indicador 910406, periodo 2026/09)
|
|
46
|
+
- *¿Cuánta gente vive en Naucalpan?* → 834,434 personas (Censo 2020, municipio 15057)
|
|
47
|
+
- *¿Qué estados tienen más población desocupada?* → compara las 32 entidades con la ENOE
|
|
48
|
+
- *¿Cómo va el PIB?* → variación anual, PIB real en millones de pesos de 2018 e IGAE mensual
|
|
49
|
+
|
|
50
|
+
Cada respuesta trae el ID del indicador, el periodo, la unidad, la fuente oficial y la fecha de
|
|
51
|
+
última actualización, para que cualquiera la pueda verificar.
|
|
52
|
+
|
|
53
|
+
## Herramientas
|
|
54
|
+
|
|
55
|
+
| Herramienta | Qué hace |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `indicadores_clave` | Catálogo de indicadores verificados, con su ID y cobertura geográfica |
|
|
58
|
+
| `consultar_indicador` | Último valor o serie de un indicador para el país, un estado o un municipio |
|
|
59
|
+
| `comparar_areas` | El mismo indicador en varios estados o municipios, ordenado de mayor a menor |
|
|
60
|
+
| `buscar_municipio` | Clave de un municipio o alcaldía por nombre, sin importar acentos |
|
|
61
|
+
| `metadatos_indicador` | Nombre oficial, unidad, frecuencia, fuente y periodo cubierto |
|
|
62
|
+
|
|
63
|
+
`consultar_indicador` acepta las claves del catálogo (`INFLACION_ANUAL`, `POBLACION_TOTAL`,
|
|
64
|
+
`TASA_DESOCUPACION`, `PIB_REAL`, `IGAE`…) o cualquier ID numérico del Banco de Indicadores.
|
|
65
|
+
Las áreas se pueden dar por nombre o por clave: estados (`Jalisco`, `CDMX`, `14`) y los 2,478
|
|
66
|
+
municipios y alcaldías (`Naucalpan`, `Juárez, Chihuahua`, `15057`). Si un nombre existe en varios
|
|
67
|
+
estados, como Benito Juárez, el servidor devuelve los candidatos en lugar de adivinar.
|
|
68
|
+
|
|
69
|
+
## Instalación
|
|
70
|
+
|
|
71
|
+
1. Pide tu token gratuito en el
|
|
72
|
+
[INEGI](https://www.inegi.org.mx/app/desarrolladores/generatoken/Usuarios/token_Verify).
|
|
73
|
+
2. Verifica la conexión:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
export INEGI_TOKEN="tu_token"
|
|
77
|
+
uvx mcp-inegi --test
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
3. Agrégalo a tu cliente MCP. En Claude Desktop (`claude_desktop_config.json`):
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"inegi": {
|
|
86
|
+
"command": "uvx",
|
|
87
|
+
"args": ["mcp-inegi"],
|
|
88
|
+
"env": { "INEGI_TOKEN": "tu_token" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
En Claude Code:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
claude mcp add inegi -e INEGI_TOKEN=tu_token -- uvx mcp-inegi
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Servidor remoto
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
mcp-inegi --transport streamable-http --host 127.0.0.1 --port 8000 --allowed-host tu.dominio
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Detrás de un proxy inverso, `--allowed-host` declara el nombre público para la protección contra
|
|
107
|
+
DNS rebinding.
|
|
108
|
+
|
|
109
|
+
## Notas sobre la API del INEGI
|
|
110
|
+
|
|
111
|
+
Cosas que descubrimos al construirlo (octubre de 2026) y que el servidor ya resuelve por ti:
|
|
112
|
+
|
|
113
|
+
- El banco económico se llama `BIE-BISE`; con `BIE` a secas la API responde que no hay resultados.
|
|
114
|
+
- Se pide siempre la serie completa: con `reciente=true` la API falla en municipios cuando el
|
|
115
|
+
periodo nacional más nuevo (p. ej. 2025) no existe a ese nivel.
|
|
116
|
+
- El token va en la URL, así que el servidor silencia los logs de `httpx` para no filtrarlo.
|
|
117
|
+
- El catálogo de municipios viene incluido en el paquete (`scripts/generar_municipios.py` lo
|
|
118
|
+
regenera desde el Marco Geoestadístico).
|
|
119
|
+
- Las respuestas se guardan en caché una hora (`INEGI_CACHE_TTL`, en segundos).
|
|
120
|
+
|
|
121
|
+
## Desarrollo
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv venv && uv pip install -e ".[dev]"
|
|
125
|
+
pytest
|
|
126
|
+
ruff check .
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Licencia MIT.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# mcp-inegi
|
|
2
|
+
|
|
3
|
+
Servidor MCP para que cualquier asistente de IA (Claude, ChatGPT, Gemini, Cursor o un modelo local)
|
|
4
|
+
consulte **indicadores oficiales del INEGI** y conteste citando la fuente: población, inflación,
|
|
5
|
+
empleo, PIB e IGAE, a nivel nacional, estatal y, para datos censales, municipal.
|
|
6
|
+
|
|
7
|
+
Forma parte de [Itzamná](https://inakisobera.me/itzamna/), una familia de servidores MCP de código
|
|
8
|
+
abierto sobre los datos públicos de México. Proyecto independiente, sin afiliación con el INEGI.
|
|
9
|
+
|
|
10
|
+
## Qué puedes preguntarle
|
|
11
|
+
|
|
12
|
+
- *¿Cuál fue la inflación anual de septiembre?* → 3.45 % (INPC, indicador 910406, periodo 2026/09)
|
|
13
|
+
- *¿Cuánta gente vive en Naucalpan?* → 834,434 personas (Censo 2020, municipio 15057)
|
|
14
|
+
- *¿Qué estados tienen más población desocupada?* → compara las 32 entidades con la ENOE
|
|
15
|
+
- *¿Cómo va el PIB?* → variación anual, PIB real en millones de pesos de 2018 e IGAE mensual
|
|
16
|
+
|
|
17
|
+
Cada respuesta trae el ID del indicador, el periodo, la unidad, la fuente oficial y la fecha de
|
|
18
|
+
última actualización, para que cualquiera la pueda verificar.
|
|
19
|
+
|
|
20
|
+
## Herramientas
|
|
21
|
+
|
|
22
|
+
| Herramienta | Qué hace |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `indicadores_clave` | Catálogo de indicadores verificados, con su ID y cobertura geográfica |
|
|
25
|
+
| `consultar_indicador` | Último valor o serie de un indicador para el país, un estado o un municipio |
|
|
26
|
+
| `comparar_areas` | El mismo indicador en varios estados o municipios, ordenado de mayor a menor |
|
|
27
|
+
| `buscar_municipio` | Clave de un municipio o alcaldía por nombre, sin importar acentos |
|
|
28
|
+
| `metadatos_indicador` | Nombre oficial, unidad, frecuencia, fuente y periodo cubierto |
|
|
29
|
+
|
|
30
|
+
`consultar_indicador` acepta las claves del catálogo (`INFLACION_ANUAL`, `POBLACION_TOTAL`,
|
|
31
|
+
`TASA_DESOCUPACION`, `PIB_REAL`, `IGAE`…) o cualquier ID numérico del Banco de Indicadores.
|
|
32
|
+
Las áreas se pueden dar por nombre o por clave: estados (`Jalisco`, `CDMX`, `14`) y los 2,478
|
|
33
|
+
municipios y alcaldías (`Naucalpan`, `Juárez, Chihuahua`, `15057`). Si un nombre existe en varios
|
|
34
|
+
estados, como Benito Juárez, el servidor devuelve los candidatos en lugar de adivinar.
|
|
35
|
+
|
|
36
|
+
## Instalación
|
|
37
|
+
|
|
38
|
+
1. Pide tu token gratuito en el
|
|
39
|
+
[INEGI](https://www.inegi.org.mx/app/desarrolladores/generatoken/Usuarios/token_Verify).
|
|
40
|
+
2. Verifica la conexión:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export INEGI_TOKEN="tu_token"
|
|
44
|
+
uvx mcp-inegi --test
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Agrégalo a tu cliente MCP. En Claude Desktop (`claude_desktop_config.json`):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"inegi": {
|
|
53
|
+
"command": "uvx",
|
|
54
|
+
"args": ["mcp-inegi"],
|
|
55
|
+
"env": { "INEGI_TOKEN": "tu_token" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
En Claude Code:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude mcp add inegi -e INEGI_TOKEN=tu_token -- uvx mcp-inegi
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Servidor remoto
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
mcp-inegi --transport streamable-http --host 127.0.0.1 --port 8000 --allowed-host tu.dominio
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Detrás de un proxy inverso, `--allowed-host` declara el nombre público para la protección contra
|
|
74
|
+
DNS rebinding.
|
|
75
|
+
|
|
76
|
+
## Notas sobre la API del INEGI
|
|
77
|
+
|
|
78
|
+
Cosas que descubrimos al construirlo (octubre de 2026) y que el servidor ya resuelve por ti:
|
|
79
|
+
|
|
80
|
+
- El banco económico se llama `BIE-BISE`; con `BIE` a secas la API responde que no hay resultados.
|
|
81
|
+
- Se pide siempre la serie completa: con `reciente=true` la API falla en municipios cuando el
|
|
82
|
+
periodo nacional más nuevo (p. ej. 2025) no existe a ese nivel.
|
|
83
|
+
- El token va en la URL, así que el servidor silencia los logs de `httpx` para no filtrarlo.
|
|
84
|
+
- El catálogo de municipios viene incluido en el paquete (`scripts/generar_municipios.py` lo
|
|
85
|
+
regenera desde el Marco Geoestadístico).
|
|
86
|
+
- Las respuestas se guardan en caché una hora (`INEGI_CACHE_TTL`, en segundos).
|
|
87
|
+
|
|
88
|
+
## Desarrollo
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv venv && uv pip install -e ".[dev]"
|
|
92
|
+
pytest
|
|
93
|
+
ruff check .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Licencia MIT.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Resolución de áreas geográficas: país, estados y municipios por nombre o por clave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import unicodedata
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
|
|
10
|
+
from .constants import AREA_NACIONAL, ESTADOS
|
|
11
|
+
|
|
12
|
+
# Nombres comunes que no coinciden con el nombre oficial del estado.
|
|
13
|
+
_ALIAS_ESTADOS = {
|
|
14
|
+
"nacional": "00",
|
|
15
|
+
"mexico": "00",
|
|
16
|
+
"republica mexicana": "00",
|
|
17
|
+
"estados unidos mexicanos": "00",
|
|
18
|
+
"cdmx": "09",
|
|
19
|
+
"df": "09",
|
|
20
|
+
"distrito federal": "09",
|
|
21
|
+
"edomex": "15",
|
|
22
|
+
"estado de mexico": "15",
|
|
23
|
+
"coahuila": "05",
|
|
24
|
+
"michoacan": "16",
|
|
25
|
+
"veracruz": "30",
|
|
26
|
+
"queretaro de arteaga": "22",
|
|
27
|
+
"nl": "19",
|
|
28
|
+
"bc": "02",
|
|
29
|
+
"bcs": "03",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Prefijos que la gente antepone al nombre del municipio.
|
|
33
|
+
_PREFIJOS = ("municipio de ", "municipio ", "alcaldia de ", "alcaldia ", "delegacion ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AreaAmbiguaError(ValueError):
|
|
37
|
+
"""El nombre coincide con municipios de varios estados."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, texto: str, candidatos: list[dict[str, str]]):
|
|
40
|
+
self.candidatos = candidatos
|
|
41
|
+
opciones = "; ".join(f"{c['nombre']}, {c['estado']} ({c['clave']})" for c in candidatos[:10])
|
|
42
|
+
extra = f" y {len(candidatos) - 10} más" if len(candidatos) > 10 else ""
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"'{texto}' coincide con {len(candidatos)} municipios: {opciones}{extra}. "
|
|
45
|
+
"Indica el estado ('Benito Juárez, CDMX') o usa la clave de 5 dígitos."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalizar(texto: str) -> str:
|
|
50
|
+
"""Minúsculas, sin acentos y con espacios simples."""
|
|
51
|
+
sin_acentos = unicodedata.normalize("NFD", texto)
|
|
52
|
+
sin_acentos = "".join(c for c in sin_acentos if unicodedata.category(c) != "Mn")
|
|
53
|
+
return " ".join(sin_acentos.lower().replace(".", " ").split())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_POR_NOMBRE = {normalizar(nombre): clave for clave, nombre in ESTADOS.items()}
|
|
57
|
+
_POR_NOMBRE.update(_ALIAS_ESTADOS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@lru_cache(maxsize=1)
|
|
61
|
+
def municipios() -> dict[str, str]:
|
|
62
|
+
"""Catálogo {clave de 5 dígitos: nombre oficial}, generado por scripts/generar_municipios.py."""
|
|
63
|
+
texto = files("mcp_inegi").joinpath("data/municipios.json").read_text(encoding="utf-8")
|
|
64
|
+
return json.loads(texto)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=1)
|
|
68
|
+
def _municipios_normalizados() -> list[tuple[str, str]]:
|
|
69
|
+
return [(clave, normalizar(nombre)) for clave, nombre in municipios().items()]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _estado(texto: str, *, en_contexto: bool = False) -> str | None:
|
|
73
|
+
"""Clave de 2 dígitos de un estado por nombre o número, o None.
|
|
74
|
+
|
|
75
|
+
Con `en_contexto` ("Naucalpan, México") "México" es el estado, no el país.
|
|
76
|
+
"""
|
|
77
|
+
t = normalizar(texto)
|
|
78
|
+
if t.isdigit() and len(t) <= 2 and t.zfill(2) in ESTADOS:
|
|
79
|
+
return t.zfill(2)
|
|
80
|
+
if en_contexto and t == "mexico":
|
|
81
|
+
return "15"
|
|
82
|
+
clave = _POR_NOMBRE.get(t)
|
|
83
|
+
return None if clave == AREA_NACIONAL and en_contexto else clave
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ficha(clave: str) -> dict[str, str]:
|
|
87
|
+
return {"clave": clave, "nombre": municipios()[clave], "estado": ESTADOS[clave[:2]]}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def buscar_municipios(texto: str, estado: str | None = None) -> list[dict[str, str]]:
|
|
91
|
+
"""Municipios que coinciden con `texto`, del mejor nivel de coincidencia disponible.
|
|
92
|
+
|
|
93
|
+
Niveles: nombre exacto > el nombre empieza con el texto ("naucalpan" -> "Naucalpan de
|
|
94
|
+
Juárez") > todas las palabras del texto aparecen en el nombre. Se devuelve solo el mejor
|
|
95
|
+
nivel con resultados, para que "Juárez" no arrastre a todos los "Benito Juárez".
|
|
96
|
+
"""
|
|
97
|
+
q = normalizar(texto)
|
|
98
|
+
for prefijo in _PREFIJOS:
|
|
99
|
+
if q.startswith(prefijo):
|
|
100
|
+
q = q[len(prefijo):]
|
|
101
|
+
break
|
|
102
|
+
if not q:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
clave_estado = None
|
|
106
|
+
if estado:
|
|
107
|
+
clave_estado = _estado(estado, en_contexto=True)
|
|
108
|
+
if clave_estado is None:
|
|
109
|
+
raise ValueError(f"No reconozco el estado '{estado}'.")
|
|
110
|
+
|
|
111
|
+
universo = [
|
|
112
|
+
(clave, nombre)
|
|
113
|
+
for clave, nombre in _municipios_normalizados()
|
|
114
|
+
if clave_estado is None or clave.startswith(clave_estado)
|
|
115
|
+
]
|
|
116
|
+
palabras = q.split()
|
|
117
|
+
niveles = (
|
|
118
|
+
lambda n: n == q,
|
|
119
|
+
lambda n: n.startswith(q + " "),
|
|
120
|
+
lambda n: all(p in n.split() for p in palabras),
|
|
121
|
+
)
|
|
122
|
+
for coincide in niveles:
|
|
123
|
+
encontrados = [clave for clave, nombre in universo if coincide(nombre)]
|
|
124
|
+
if encontrados:
|
|
125
|
+
return [_ficha(c) for c in encontrados]
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolver_area(area: str | None) -> str:
|
|
130
|
+
"""Convierte un nombre o clave de área geográfica a la clave que espera la API.
|
|
131
|
+
|
|
132
|
+
Acepta "00"/"nacional", la clave de estado con o sin cero ("9" o "09"), el nombre del
|
|
133
|
+
estado ("Jalisco", "CDMX", "Edomex"), la clave de municipio de 5 dígitos ("15057") o su
|
|
134
|
+
nombre ("Naucalpan", "Benito Juárez, CDMX"). Si un nombre es a la vez estado y municipio
|
|
135
|
+
("Aguascalientes", "Hidalgo") gana el estado; para el municipio, "Hidalgo, Michoacán".
|
|
136
|
+
"México" a secas se toma como el país; el estado es "Estado de México" o "15".
|
|
137
|
+
"""
|
|
138
|
+
if area is None or not str(area).strip():
|
|
139
|
+
return AREA_NACIONAL
|
|
140
|
+
texto = str(area).strip()
|
|
141
|
+
|
|
142
|
+
if texto.isdigit():
|
|
143
|
+
if len(texto) <= 2:
|
|
144
|
+
clave = texto.zfill(2)
|
|
145
|
+
if clave == AREA_NACIONAL or clave in ESTADOS:
|
|
146
|
+
return clave
|
|
147
|
+
elif len(texto) == 5 and texto in municipios():
|
|
148
|
+
return texto
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Clave de área '{texto}' no válida. Usa '00' (nacional), 2 dígitos para estado "
|
|
151
|
+
"(p. ej. '09') o 5 dígitos para municipio (estado + municipio, p. ej. '15057')."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if "," in texto:
|
|
155
|
+
nombre, _, estado = texto.rpartition(",")
|
|
156
|
+
candidatos = buscar_municipios(nombre, estado)
|
|
157
|
+
else:
|
|
158
|
+
clave = _POR_NOMBRE.get(normalizar(texto))
|
|
159
|
+
if clave is not None:
|
|
160
|
+
return clave
|
|
161
|
+
candidatos = buscar_municipios(texto)
|
|
162
|
+
|
|
163
|
+
if len(candidatos) == 1:
|
|
164
|
+
return candidatos[0]["clave"]
|
|
165
|
+
if candidatos:
|
|
166
|
+
raise AreaAmbiguaError(texto, candidatos)
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"No reconozco el área '{texto}'. Usa el nombre de un estado o municipio "
|
|
169
|
+
"('Jalisco', 'Naucalpan', 'Benito Juárez, CDMX') o su clave numérica."
|
|
170
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Interfaz de línea de comandos para mcp-inegi."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .client import InegiAPIError, InegiClient
|
|
12
|
+
from .constants import INEGI_TOKEN_HELP_URL
|
|
13
|
+
from .server import create_server
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_transport_security(allowed_hosts: list[str] | None):
|
|
17
|
+
"""Arma la configuración anti DNS-rebinding para los transportes HTTP.
|
|
18
|
+
|
|
19
|
+
Detrás de un proxy inverso el header Host que llega es el nombre público,
|
|
20
|
+
que la librería no conoce: sin declararlo responde 421 a cada petición.
|
|
21
|
+
Devolver None deja que la librería aplique su default para loopback.
|
|
22
|
+
"""
|
|
23
|
+
if not allowed_hosts:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
27
|
+
|
|
28
|
+
hosts: list[str] = []
|
|
29
|
+
origins: list[str] = []
|
|
30
|
+
for nombre in allowed_hosts:
|
|
31
|
+
# El proxy puede o no incluir el puerto en el Host.
|
|
32
|
+
for entrada in (nombre, f"{nombre}:*"):
|
|
33
|
+
if entrada not in hosts:
|
|
34
|
+
hosts.append(entrada)
|
|
35
|
+
for entrada in (f"https://{nombre}", f"http://{nombre}"):
|
|
36
|
+
if entrada not in origins:
|
|
37
|
+
origins.append(entrada)
|
|
38
|
+
|
|
39
|
+
# Conservar loopback para que las pruebas locales sigan funcionando.
|
|
40
|
+
for entrada in ("127.0.0.1:*", "localhost:*", "[::1]:*"):
|
|
41
|
+
if entrada not in hosts:
|
|
42
|
+
hosts.append(entrada)
|
|
43
|
+
for entrada in ("http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"):
|
|
44
|
+
if entrada not in origins:
|
|
45
|
+
origins.append(entrada)
|
|
46
|
+
|
|
47
|
+
return TransportSecuritySettings(
|
|
48
|
+
enable_dns_rebinding_protection=True,
|
|
49
|
+
allowed_hosts=hosts,
|
|
50
|
+
allowed_origins=origins,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_connection(token: str | None = None) -> int:
|
|
55
|
+
"""Prueba la conexión a la API del INEGI consultando la inflación anual más reciente."""
|
|
56
|
+
print("=" * 60)
|
|
57
|
+
print(f"mcp-inegi v{__version__} - Verificación de conexión a la API del INEGI")
|
|
58
|
+
print("=" * 60)
|
|
59
|
+
|
|
60
|
+
effective_token = token or os.environ.get("INEGI_TOKEN")
|
|
61
|
+
if not effective_token:
|
|
62
|
+
print("\n[AVISO] No se detectó la variable de entorno INEGI_TOKEN.")
|
|
63
|
+
print(f"Para obtener un token gratuito, visita: {INEGI_TOKEN_HELP_URL}\n")
|
|
64
|
+
print("Ejemplo de uso:")
|
|
65
|
+
print(' export INEGI_TOKEN="tu_token_aqui"')
|
|
66
|
+
print(" mcp-inegi\n")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
client = InegiClient(token=effective_token)
|
|
70
|
+
|
|
71
|
+
async def _run():
|
|
72
|
+
print("Consultando inflación anual (INPC, indicador 910406)...")
|
|
73
|
+
try:
|
|
74
|
+
res = await client.consultar("INFLACION_ANUAL")
|
|
75
|
+
ultimo = res["ultimo"] or {}
|
|
76
|
+
print(f"-> Éxito: periodo {ultimo.get('periodo')}, inflación anual {ultimo.get('valor')} %")
|
|
77
|
+
return 0
|
|
78
|
+
except InegiAPIError as e:
|
|
79
|
+
print(f"[ERROR] Falló la consulta al INEGI: {e}")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
return asyncio.run(_run())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> None:
|
|
86
|
+
"""Punto de entrada principal para el comando mcp-inegi."""
|
|
87
|
+
parser = argparse.ArgumentParser(
|
|
88
|
+
prog="mcp-inegi",
|
|
89
|
+
description=(
|
|
90
|
+
"Servidor MCP comunitario para consultar indicadores oficiales del INEGI. "
|
|
91
|
+
"Parte de Itzamná; proyecto independiente, sin afiliación con el INEGI."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("--version", action="version", version=f"mcp-inegi {__version__}")
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--token",
|
|
97
|
+
type=str,
|
|
98
|
+
default=None,
|
|
99
|
+
help="Token de la API de Indicadores del INEGI (o configurar variable INEGI_TOKEN)",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--test",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Verifica la conectividad con la API del INEGI y muestra la inflación anual",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--transport",
|
|
108
|
+
choices=["stdio", "sse", "streamable-http"],
|
|
109
|
+
default="stdio",
|
|
110
|
+
help=(
|
|
111
|
+
"Tipo de transporte MCP (por defecto: stdio). "
|
|
112
|
+
"'streamable-http' sirve el endpoint remoto; 'sse' está deprecado."
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--host",
|
|
117
|
+
default="127.0.0.1",
|
|
118
|
+
help="Host de escucha para transportes HTTP (por defecto: 127.0.0.1)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--allowed-host",
|
|
122
|
+
action="append",
|
|
123
|
+
default=[],
|
|
124
|
+
metavar="HOST",
|
|
125
|
+
help=(
|
|
126
|
+
"Nombre público aceptado en el header Host al correr detrás de un "
|
|
127
|
+
"proxy inverso. Repetible. Sin esto el servidor rechaza con 421."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--port",
|
|
132
|
+
type=int,
|
|
133
|
+
default=8000,
|
|
134
|
+
help="Puerto de escucha para transportes HTTP (por defecto: 8000)",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
|
|
139
|
+
if args.test:
|
|
140
|
+
sys.exit(test_connection(args.token))
|
|
141
|
+
|
|
142
|
+
server = create_server(token=args.token)
|
|
143
|
+
|
|
144
|
+
if args.transport == "stdio":
|
|
145
|
+
server.run(transport="stdio")
|
|
146
|
+
else:
|
|
147
|
+
# Detrás de nginx esto se queda en loopback y el proxy publica el TLS.
|
|
148
|
+
server.run(
|
|
149
|
+
transport=args.transport,
|
|
150
|
+
host=args.host,
|
|
151
|
+
port=args.port,
|
|
152
|
+
transport_security=build_transport_security(args.allowed_host),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|