indec-mcp 0.5.0__py3-none-any.whl

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.
indec_mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Unofficial AI/MCP layer over INDEC's Catálogo API-INDEC. Fuente: INDEC."""
indec_mcp/catalog.py ADDED
@@ -0,0 +1,83 @@
1
+ """Dataset registry parsed from INDEC's OpenAPI spec.
2
+
3
+ The spec is public (GET https://api.indec.gob.ar/v1/ needs no token) and carries everything
4
+ the sync needs: endpoint paths, the full primary key per table (required for stable
5
+ pagination), and a human description per column.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ _PACKAGED_SPEC = Path(__file__).resolve().parent / "indec-openapi.json" # wheel installs
16
+ _REPO_SPEC = Path(__file__).resolve().parents[2] / "spec" / "indec-openapi.json" # source checkout
17
+ SPEC_SNAPSHOT = _PACKAGED_SPEC if _PACKAGED_SPEC.exists() else _REPO_SPEC
18
+
19
+ # Query params PostgREST adds to every endpoint; not data columns.
20
+ _STD_PARAMS = {"select", "order", "limit", "offset", "Prefer"}
21
+ _PK_MARKER = "clave completa:"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Dataset:
26
+ path: str # API path, e.g. "/canasta-crianza"
27
+ table: str # DuckDB table name, e.g. "canasta_crianza"
28
+ summary: str
29
+ description: str
30
+ directorate: str # INDEC directorate (OpenAPI tag)
31
+ primary_key: tuple[str, ...] # column names as the API spells them
32
+ column_docs: dict[str, str] = field(default_factory=dict) # api column -> description
33
+
34
+
35
+ def table_name(path: str) -> str:
36
+ """API path -> snake_case SQL identifier."""
37
+ return re.sub(r"[^a-z0-9]+", "_", path.strip("/").lower()).strip("_")
38
+
39
+
40
+ def column_name(api_column: str) -> str:
41
+ """API column -> normalized lowercase name (API mixes `Anio` and `anio`)."""
42
+ return api_column.lower()
43
+
44
+
45
+ def _clean_column_doc(text: str) -> str:
46
+ text = re.sub(r"\s+", " ", text or "").strip()
47
+ text = re.sub(r"^Filtro para\s+", "", text)
48
+ text = re.sub(r"\s*Use operadores como.*$", "", text)
49
+ return text.rstrip(". ")
50
+
51
+
52
+ def parse_spec(spec: dict) -> list[Dataset]:
53
+ datasets = []
54
+ for path, ops in spec["paths"].items():
55
+ op = ops["get"]
56
+ params = {p["name"]: p for p in op.get("parameters", [])}
57
+ order_doc = params.get("order", {}).get("description", "")
58
+ if _PK_MARKER not in order_doc:
59
+ raise ValueError(f"{path}: no primary key in the 'order' parameter description")
60
+ pk = tuple(c.strip() for c in order_doc.split(_PK_MARKER, 1)[1].split(",") if c.strip())
61
+ datasets.append(
62
+ Dataset(
63
+ path=path,
64
+ table=table_name(path),
65
+ summary=op.get("summary", ""),
66
+ description=op.get("description", "").split("### ")[0].strip(),
67
+ directorate=(op.get("tags") or [""])[0],
68
+ primary_key=pk,
69
+ column_docs={
70
+ name: _clean_column_doc(p.get("description", ""))
71
+ for name, p in params.items()
72
+ if name not in _STD_PARAMS
73
+ },
74
+ )
75
+ )
76
+ tables = [d.table for d in datasets]
77
+ if len(set(tables)) != len(tables):
78
+ raise ValueError(f"table name collision: {tables}")
79
+ return datasets
80
+
81
+
82
+ def load_snapshot(path: Path = SPEC_SNAPSHOT) -> dict:
83
+ return json.loads(path.read_text(encoding="utf-8"))
indec_mcp/client.py ADDED
@@ -0,0 +1,120 @@
1
+ """Polite HTTP client for the INDEC API (PostgREST): auth, pagination, retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import time
8
+ from collections.abc import Iterator
9
+
10
+ import httpx
11
+
12
+ from .catalog import Dataset
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ BASE_URL = "https://api.indec.gob.ar/v1"
17
+ PAGE_SIZE = 1000 # server-side cap; larger `limit` values are silently clamped
18
+ USER_AGENT = "indec-mcp-sync/0.1"
19
+ _RETRY_STATUS = {429, 500, 502, 503, 504}
20
+
21
+
22
+ class AuthError(RuntimeError):
23
+ """Token missing, invalid, expired (they last ~6 months) or lacking permission."""
24
+
25
+
26
+ class IndecClient:
27
+ def __init__(
28
+ self,
29
+ token: str | None,
30
+ *,
31
+ base_url: str = BASE_URL,
32
+ delay: float = 0.2,
33
+ max_retries: int = 5,
34
+ transport: httpx.BaseTransport | None = None,
35
+ sleep=time.sleep,
36
+ ):
37
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
38
+ if token:
39
+ headers["Authorization"] = f"Bearer {token}"
40
+ self._http = httpx.Client(
41
+ base_url=base_url, headers=headers, timeout=60, transport=transport
42
+ )
43
+ self.delay = delay
44
+ self.max_retries = max_retries
45
+ self._sleep = sleep
46
+ self.requests_made = 0
47
+
48
+ def close(self) -> None:
49
+ self._http.close()
50
+
51
+ def __enter__(self):
52
+ return self
53
+
54
+ def __exit__(self, *exc):
55
+ self.close()
56
+
57
+ def get(self, path: str, params: dict | None = None, headers: dict | None = None) -> httpx.Response:
58
+ for attempt in range(self.max_retries + 1):
59
+ if self.requests_made:
60
+ self._sleep(self.delay)
61
+ self.requests_made += 1
62
+ try:
63
+ resp = self._http.get(path, params=params, headers=headers)
64
+ except httpx.TransportError as e:
65
+ if attempt == self.max_retries:
66
+ raise
67
+ wait = 2**attempt
68
+ log.warning("%s: %s; retrying in %ss", path, e, wait)
69
+ self._sleep(wait)
70
+ continue
71
+ if resp.status_code in (401, 403):
72
+ raise AuthError(f"{resp.status_code} on {path}: {resp.text[:200]}")
73
+ if resp.status_code in _RETRY_STATUS and attempt < self.max_retries:
74
+ wait = _retry_after(resp) or 2**attempt
75
+ log.warning("%s: HTTP %s; retrying in %ss", path, resp.status_code, wait)
76
+ self._sleep(wait)
77
+ continue
78
+ resp.raise_for_status()
79
+ return resp
80
+ raise AssertionError("unreachable")
81
+
82
+ def spec(self) -> dict:
83
+ """Live OpenAPI spec; public, no token needed."""
84
+ return self.get("/").json()
85
+
86
+ def iter_pages(self, ds: Dataset) -> Iterator[tuple[list[dict], int | None]]:
87
+ """Yield (rows, total) per page, ordered by the full primary key so pages are stable.
88
+
89
+ `total` comes from Content-Range on the first page (Prefer: count=exact) and is None
90
+ afterwards, so the expensive count runs once per dataset.
91
+ """
92
+ order = ",".join(ds.primary_key)
93
+ offset = 0
94
+ total = None
95
+ while True:
96
+ headers = {"Prefer": "count=exact"} if offset == 0 else None
97
+ resp = self.get(
98
+ ds.path,
99
+ params={"order": order, "limit": PAGE_SIZE, "offset": offset},
100
+ headers=headers,
101
+ )
102
+ rows = resp.json()
103
+ if offset == 0:
104
+ total = _content_range_total(resp.headers.get("content-range"))
105
+ yield rows, (total if offset == 0 else None)
106
+ offset += len(rows)
107
+ if not rows or len(rows) < PAGE_SIZE or (total is not None and offset >= total):
108
+ return
109
+
110
+
111
+ def _content_range_total(value: str | None) -> int | None:
112
+ m = re.search(r"/(\d+)$", value or "")
113
+ return int(m.group(1)) if m else None
114
+
115
+
116
+ def _retry_after(resp: httpx.Response) -> float | None:
117
+ try:
118
+ return float(resp.headers["retry-after"])
119
+ except (KeyError, ValueError):
120
+ return None
indec_mcp/hosted.py ADDED
@@ -0,0 +1,273 @@
1
+ """Hosted entry point: MCP over Streamable HTTP with abuse protection and a daily sync.
2
+
3
+ Run: `indec-mcp-http` (reads PORT, INDEC_DB, INDEC_TOKEN and the limits below from env).
4
+
5
+ Abuse protection, outermost first:
6
+ 1. uvicorn `limit_concurrency`: connections beyond it get 503 immediately;
7
+ 2. RateLimitMiddleware: per-IP burst and daily buckets plus a global bucket -> 429;
8
+ 3. request bodies capped at 64 KB (SQL queries are small);
9
+ 4. Mirror: read-only sandboxed DuckDB, max 4 concurrent queries, 5 s timeout, 500-row cap.
10
+ No LLM runs here (clients bring their own), so cost per request is CPU only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hmac
16
+ import logging
17
+ import os
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from mcp.server.transport_security import TransportSecuritySettings
22
+ from starlette.requests import Request
23
+ from starlette.responses import FileResponse, JSONResponse, Response
24
+
25
+ from .mirror import SOURCE, Limits, Mirror, QueryError
26
+ from .paths import default_db
27
+ from .providers import ProviderRanges
28
+ from .ratelimit import RateLimiter, RateLimitMiddleware, Rule
29
+ from .scheduler import SyncScheduler
30
+ from .server import create_server
31
+ from .snapshot import SnapshotPublisher
32
+ from .usage import UsageMiddleware, UsageRecorder
33
+
34
+ log = logging.getLogger("indec_mcp.http")
35
+
36
+ MAX_BODY_BYTES = 64 * 1024
37
+
38
+
39
+ def _env_float(name: str, default: float) -> float:
40
+ v = os.environ.get(name)
41
+ return float(v) if v not in (None, "") else default
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Settings:
46
+ db_path: Path
47
+ port: int = 8000
48
+ per_ip_per_minute: float = 30 # sustained; a chat turn is ~3-10 tool calls
49
+ per_ip_burst: float = 20
50
+ per_ip_per_day: float = 2000
51
+ global_per_second: float = 20
52
+ global_burst: float = 100
53
+ # Claude.ai / ChatGPT call from shared provider IPs (one user arrived from 6 Anthropic IPs), so
54
+ # each provider gets one bucket for all its users, sized well above a single person's.
55
+ provider_burst: float = 200
56
+ provider_per_minute: float = 600
57
+ provider_per_day: float = 100_000
58
+ # Railway overwrites X-Real-IP with the client IP (documented, and verified in production, see docs/deploy.md). Its
59
+ # X-Forwarded-For rightmost entry is an internal hop that varies per request, so it can't key
60
+ # rate limits. Set CLIENT_IP_HEADER="" to fall back to X-Forwarded-For + TRUSTED_PROXY_HOPS.
61
+ ip_header: str | None = "x-real-ip"
62
+ proxy_hops: int = 1
63
+ max_connections: int = 64
64
+ sync_enabled: bool = True
65
+ sync_hour_utc: int = 22
66
+ usage_enabled: bool = True
67
+ usage_retention_days: int = 90
68
+ stats_token: str | None = None # enables GET /stats; unset -> 404
69
+ # GET /snapshot/indec.duckdb.gz (~12 MB) feeds local installs. A local server checks every 6h
70
+ # and mostly gets a 304, so these only bite on abuse; the global cap bounds egress.
71
+ snapshot_enabled: bool = True
72
+ snapshot_per_ip_per_hour: float = 6
73
+ snapshot_per_ip_per_day: float = 30
74
+ snapshot_global_per_hour: float = 120
75
+
76
+ @property
77
+ def usage_db(self) -> Path:
78
+ return self.db_path.parent / "usage.sqlite"
79
+
80
+ @classmethod
81
+ def from_env(cls) -> "Settings":
82
+ return cls(
83
+ db_path=default_db(),
84
+ port=int(os.environ.get("PORT", 8000)),
85
+ per_ip_per_minute=_env_float("RATE_PER_IP_PER_MINUTE", cls.per_ip_per_minute),
86
+ per_ip_burst=_env_float("RATE_PER_IP_BURST", cls.per_ip_burst),
87
+ per_ip_per_day=_env_float("RATE_PER_IP_PER_DAY", cls.per_ip_per_day),
88
+ global_per_second=_env_float("RATE_GLOBAL_PER_SECOND", cls.global_per_second),
89
+ global_burst=_env_float("RATE_GLOBAL_BURST", cls.global_burst),
90
+ provider_burst=_env_float("RATE_PROVIDER_BURST", cls.provider_burst),
91
+ provider_per_minute=_env_float("RATE_PROVIDER_PER_MINUTE", cls.provider_per_minute),
92
+ provider_per_day=_env_float("RATE_PROVIDER_PER_DAY", cls.provider_per_day),
93
+ ip_header=os.environ.get("CLIENT_IP_HEADER", cls.ip_header) or None,
94
+ proxy_hops=int(os.environ.get("TRUSTED_PROXY_HOPS", cls.proxy_hops)),
95
+ max_connections=int(os.environ.get("MAX_CONNECTIONS", cls.max_connections)),
96
+ sync_enabled=os.environ.get("SYNC_ENABLED", "1") != "0",
97
+ sync_hour_utc=int(os.environ.get("SYNC_HOUR_UTC", cls.sync_hour_utc)),
98
+ usage_enabled=os.environ.get("USAGE_ENABLED", "1") != "0",
99
+ usage_retention_days=int(os.environ.get("USAGE_RETENTION_DAYS", cls.usage_retention_days)),
100
+ stats_token=os.environ.get("STATS_TOKEN") or None,
101
+ snapshot_enabled=os.environ.get("SNAPSHOT_ENABLED", "1") != "0",
102
+ )
103
+
104
+ def limiter(self) -> RateLimiter:
105
+ return RateLimiter(
106
+ per_ip=[
107
+ # Burst bucket: `per_ip_burst` at once, refilling at per_ip_per_minute/min.
108
+ Rule(capacity=self.per_ip_burst, per_seconds=60 * self.per_ip_burst / self.per_ip_per_minute),
109
+ Rule(capacity=self.per_ip_per_day, per_seconds=86_400),
110
+ ],
111
+ global_rule=Rule(capacity=self.global_burst, per_seconds=self.global_burst / self.global_per_second),
112
+ profiles={"provider": [
113
+ Rule(capacity=self.provider_burst, per_seconds=60 * self.provider_burst / self.provider_per_minute),
114
+ Rule(capacity=self.provider_per_day, per_seconds=86_400),
115
+ ]},
116
+ )
117
+
118
+ def snapshot_limiter(self) -> RateLimiter:
119
+ return RateLimiter(
120
+ per_ip=[
121
+ Rule(capacity=self.snapshot_per_ip_per_hour, per_seconds=3600),
122
+ Rule(capacity=self.snapshot_per_ip_per_day, per_seconds=86_400),
123
+ ],
124
+ global_rule=Rule(capacity=self.snapshot_global_per_hour, per_seconds=3600),
125
+ )
126
+
127
+
128
+ def build_app(
129
+ mirror: Mirror,
130
+ settings: Settings,
131
+ scheduler: SyncScheduler | None = None,
132
+ usage: UsageRecorder | None = None,
133
+ providers: ProviderRanges | None = None,
134
+ ):
135
+ server = create_server(mirror)
136
+ snapshots = SnapshotPublisher(settings.db_path) if settings.snapshot_enabled else None
137
+
138
+ @server.custom_route("/snapshot/indec.duckdb.gz", methods=["GET", "HEAD"], include_in_schema=False)
139
+ async def snapshot(request: Request) -> Response:
140
+ # The whole mirror (public INDEC data only), gzipped, for `indec-mcp` running locally.
141
+ if snapshots is None:
142
+ return JSONResponse({"error": "not found"}, status_code=404)
143
+ import anyio
144
+
145
+ try:
146
+ path, etag = await anyio.to_thread.run_sync(snapshots.current)
147
+ except FileNotFoundError:
148
+ return JSONResponse({"error": "mirror not ready"}, status_code=503, headers={"retry-after": "600"})
149
+ tag = f'"{etag}"'
150
+ if tag in request.headers.get("if-none-match", ""):
151
+ return Response(status_code=304, headers={"etag": tag})
152
+ log.info("snapshot download %s", etag)
153
+ return FileResponse(path, media_type="application/gzip", filename="indec.duckdb.gz",
154
+ headers={"etag": tag, "cache-control": "public, max-age=3600"})
155
+
156
+ @server.custom_route("/stats", methods=["GET"], include_in_schema=False)
157
+ async def stats(request: Request) -> JSONResponse:
158
+ # Hidden unless STATS_TOKEN is configured; constant-time token check.
159
+ given = request.headers.get("authorization", "").removeprefix("Bearer ").strip()
160
+ if not settings.stats_token or usage is None:
161
+ return JSONResponse({"error": "not found"}, status_code=404)
162
+ if not hmac.compare_digest(given.encode(), settings.stats_token.encode()):
163
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
164
+ try:
165
+ days = int(request.query_params.get("days", 30))
166
+ except ValueError:
167
+ days = 30
168
+ return JSONResponse(usage.report(days))
169
+
170
+ @server.custom_route("/healthz", methods=["GET"], include_in_schema=False)
171
+ async def healthz(request: Request) -> JSONResponse:
172
+ # Always 200 so the platform keeps the service up while the first sync runs.
173
+ try:
174
+ datasets = mirror.datasets()
175
+ ready = True
176
+ except QueryError:
177
+ datasets, ready = [], False
178
+ return JSONResponse({
179
+ "status": "ok",
180
+ "mirror_ready": ready,
181
+ "datasets": len(datasets),
182
+ "stale_or_failed": [d["table_name"] for d in datasets if d["status"] != "ok"],
183
+ "synced_at": max((d["synced_at"] for d in datasets), default=None),
184
+ "last_sync": scheduler.last_result if scheduler else None,
185
+ "cache": mirror.cache.stats(),
186
+ "provider_ranges": providers.counts() if providers else None,
187
+ })
188
+
189
+ @server.custom_route("/", methods=["GET"], include_in_schema=False)
190
+ async def index(request: Request) -> JSONResponse:
191
+ return JSONResponse({
192
+ "name": "INDEC MCP (no oficial)",
193
+ "mcp_endpoint": f"{request.headers.get('x-forwarded-proto', request.url.scheme)}://"
194
+ f"{request.headers.get('host', request.url.netloc)}/mcp",
195
+ "how_to_connect": "Agregá esta URL como servidor MCP remoto (Streamable HTTP) en tu cliente: "
196
+ "Claude, ChatGPT, Cursor, etc. No requiere registro.",
197
+ "limits": {
198
+ "per_ip_per_minute": settings.per_ip_per_minute,
199
+ "per_ip_per_day": settings.per_ip_per_day,
200
+ "sql_timeout_s": mirror.limits.timeout_s,
201
+ "max_rows": mirror.limits.max_rows,
202
+ },
203
+ "source": SOURCE,
204
+ "disclaimer": "Servicio no oficial, no afiliado al INDEC. Datos: Fuente INDEC.",
205
+ })
206
+
207
+ app = server.streamable_http_app(
208
+ streamable_http_path="/mcp",
209
+ stateless_http=True, # no server-side sessions to exhaust; any instance can serve any request
210
+ json_response=True,
211
+ max_request_body_size=MAX_BODY_BYTES,
212
+ # DNS-rebinding protection guards servers on localhost from malicious web pages. This one is
213
+ # public and unauthenticated by design, so Host/Origin checks add nothing here.
214
+ transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
215
+ host="0.0.0.0",
216
+ )
217
+ classify = providers.classify if providers else None
218
+ app = RateLimitMiddleware(
219
+ app, settings.limiter(), path_prefix="/mcp", proxy_hops=settings.proxy_hops, ip_header=settings.ip_header,
220
+ classify=classify,
221
+ )
222
+ app = RateLimitMiddleware(
223
+ app, settings.snapshot_limiter(), path_prefix="/snapshot", proxy_hops=settings.proxy_hops,
224
+ ip_header=settings.ip_header,
225
+ )
226
+ if usage is not None: # outermost, so rate-limited (429) requests are counted too
227
+ app = UsageMiddleware(app, usage, path_prefix="/mcp", proxy_hops=settings.proxy_hops,
228
+ ip_header=settings.ip_header, classify=classify)
229
+ return app
230
+
231
+
232
+ def main() -> None:
233
+ import uvicorn
234
+
235
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
236
+ # Stateless mode logs "Terminating session: None" on every request.
237
+ logging.getLogger("mcp.server.streamable_http").setLevel(logging.WARNING)
238
+ settings = Settings.from_env()
239
+ mirror = Mirror(settings.db_path, Limits())
240
+
241
+ scheduler = None
242
+ if settings.sync_enabled and os.environ.get("INDEC_TOKEN"):
243
+ scheduler = SyncScheduler(
244
+ settings.db_path, hour_utc=settings.sync_hour_utc, work_dir=settings.db_path.parent
245
+ )
246
+ scheduler.start()
247
+ else:
248
+ log.warning("daily sync disabled (SYNC_ENABLED=0 or INDEC_TOKEN missing); serving existing mirror")
249
+
250
+ usage = None
251
+ if settings.usage_enabled:
252
+ usage = UsageRecorder(settings.usage_db, retention_days=settings.usage_retention_days)
253
+ log.info("usage analytics on (%s, %sd retention, /stats %s)", settings.usage_db.name,
254
+ settings.usage_retention_days, "enabled" if settings.stats_token else "disabled")
255
+
256
+ providers = ProviderRanges(cache_dir=settings.db_path.parent / "provider_ranges")
257
+ providers.start_daily_refresh()
258
+ log.info("provider ranges loaded: %s", providers.counts())
259
+
260
+ uvicorn.run(
261
+ build_app(mirror, settings, scheduler, usage, providers),
262
+ host="0.0.0.0",
263
+ port=settings.port,
264
+ proxy_headers=False, # we read X-Forwarded-For ourselves, counting trusted hops
265
+ server_header=False,
266
+ access_log=False, # don't log client IPs; rate-limit events log a hash
267
+ limit_concurrency=settings.max_connections,
268
+ timeout_keep_alive=5,
269
+ )
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()