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 +1 -0
- indec_mcp/catalog.py +83 -0
- indec_mcp/client.py +120 -0
- indec_mcp/hosted.py +273 -0
- indec_mcp/indec-openapi.json +1 -0
- indec_mcp/indices.py +260 -0
- indec_mcp/living.py +195 -0
- indec_mcp/mirror.py +775 -0
- indec_mcp/paths.py +34 -0
- indec_mcp/provider_ranges/openai-chatgpt-connectors.json +1 -0
- indec_mcp/providers.py +112 -0
- indec_mcp/ratelimit.py +182 -0
- indec_mcp/scheduler.py +84 -0
- indec_mcp/server.py +567 -0
- indec_mcp/snapshot.py +213 -0
- indec_mcp/stats_cli.py +82 -0
- indec_mcp/sync.py +323 -0
- indec_mcp/topics.py +314 -0
- indec_mcp/usage.py +353 -0
- indec_mcp-0.5.0.dist-info/METADATA +265 -0
- indec_mcp-0.5.0.dist-info/RECORD +24 -0
- indec_mcp-0.5.0.dist-info/WHEEL +4 -0
- indec_mcp-0.5.0.dist-info/entry_points.txt +5 -0
- indec_mcp-0.5.0.dist-info/licenses/LICENSE +28 -0
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()
|