almanac-mcp 0.2.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.
@@ -0,0 +1,96 @@
1
+ # build output
2
+ dist/
3
+ .astro/
4
+
5
+ # superpowers brainstorming session artifacts (mockups, server state)
6
+ .superpowers/
7
+
8
+ # Skills instaladas localmente via `npx skills add` (per-machine, no commit)
9
+ .agents/
10
+
11
+ # dependencies
12
+ node_modules/
13
+
14
+ # logs
15
+ npm-debug.log*
16
+ yarn-debug.log*
17
+ yarn-error.log*
18
+ pnpm-debug.log*
19
+
20
+ # environment
21
+ .env
22
+ .env.production
23
+ .env.local
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # editors
30
+ .vscode/
31
+ .idea/
32
+
33
+ # python
34
+ __pycache__/
35
+ *.pyc
36
+ .venv/
37
+ venv/
38
+
39
+ # bundle original (entregado en zip, no se versiona)
40
+ bcra-source-v2/
41
+
42
+ # Carpetas auxiliares del dump completo del BCRA dentro de cada snapshot.
43
+ # El pipeline solo usa los 14 .txt en el root del snapshot. El resto queda
44
+ # local (con normalize_snapshot.py --keep-extras) por si lo necesitamos en
45
+ # el futuro, pero no se sube al remote para no inflar el repo.
46
+ # `**` matchea cualquier profundidad: algunos zips traen un wrapper extra
47
+ # (ej. data/snapshots/202012/202012d/Entfin/...).
48
+ data/snapshots/**/Asociac/
49
+ data/snapshots/**/Entcam/
50
+ data/snapshots/**/Entfin/
51
+ data/snapshots/**/Info_Hist/
52
+ data/snapshots/**/Repres/
53
+ data/snapshots/**/Ayuda IEF.pdf
54
+
55
+ # vercel
56
+ .vercel/
57
+
58
+ # pytest
59
+ .pytest_cache/
60
+
61
+ # claude code (preferencias locales del editor)
62
+ .claude/settings.local.json
63
+
64
+ # almanac — entornos virtuales y artefactos locales
65
+ almanac/.venv/
66
+ almanac/mcp/Scripts/
67
+ almanac/mcp/Lib/
68
+ almanac/mcp/Include/
69
+ almanac/dist/
70
+ almanac/build/
71
+ almanac/*.egg-info/
72
+ almanac/mcp/*.egg-info/
73
+ almanac/.pytest_cache/
74
+ almanac/mcp/.pytest_cache/
75
+ almanac/.ruff_cache/
76
+ almanac/mcp/.ruff_cache/
77
+ almanac/.mypy_cache/
78
+ almanac/htmlcov/
79
+ almanac/.coverage
80
+ almanac/coverage.xml
81
+
82
+ # secrets locales de almanac
83
+ almanac/.env
84
+ almanac/.env.*
85
+ !almanac/.env.example
86
+
87
+ # Working folder local de assets para LinkedIn (PPTX, screenshots, scripts).
88
+ # No se versiona — son artefactos de marketing personal, no del producto.
89
+ _linkedin/
90
+
91
+ # Fixtures pesadas que no se commiten (.7z BCRA mensuales ~33 MB c/u, XLS).
92
+ # El scraper baja desde la URL oficial en CI; el archivo extraido sirve solo
93
+ # para iteracion local. Si se necesita reproducibilidad de un mes especifico,
94
+ # se puede pasar a gold/ en R2 con pin de hash.
95
+ almanac/.fixtures/bcra_entidades_financieras_padron/*.7z
96
+ almanac/.fixtures/bcra_entidades_financieras_padron/202*/
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: almanac-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for Almanac — Argentine financial data (BCRA, CNV, INDEC, SEC) for Claude Desktop, Cursor, Copilot
5
+ Project-URL: Homepage, https://almanac.ar
6
+ Project-URL: Documentation, https://almanac.ar/mcp
7
+ Project-URL: Source, https://github.com/nicolascolombo/Almanac
8
+ Author: Nicolás Colombo
9
+ License: Proprietary
10
+ Keywords: almanac,argentina,bcra,claude,cnv,financial-data,indec,mcp,sec
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Operating System :: OS Independent
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: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: mcp>=1.0
25
+ Requires-Dist: pydantic>=2.8
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8.3; extra == 'dev'
29
+ Requires-Dist: respx>=0.21; extra == 'dev'
30
+ Requires-Dist: ruff>=0.7; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # almanac-mcp
34
+
35
+ MCP server for [Almanac](https://almanac.ar) — Argentine financial and
36
+ economic data (BCRA, CNV, INDEC, SEC) exposed directly to your AI
37
+ assistant (Claude Desktop, Cursor, Copilot, etc.) via the Model Context
38
+ Protocol.
39
+
40
+ ## What it does
41
+
42
+ Once connected, your AI assistant can:
43
+
44
+ - **List the catalog** of published datasets (BCRA monetarias,
45
+ tipos de cambio, INDEC IPC, CNV hechos relevantes, SEC EDGAR XBRL
46
+ filings, and more)
47
+ - **Inspect schemas** column-by-column with descriptions, types, and
48
+ pre-built example queries
49
+ - **Run SQL** directly against the production datasets — sub-second
50
+ latency, cross-dataset JOINs work natively (e.g. join INDEC IPC with
51
+ BCRA reservas, or SEC financial facts with CNV hechos relevantes by
52
+ CUIT)
53
+ - **Download full snapshots** as signed parquet/CSV URLs to work locally
54
+ with DuckDB, polars, pandas, R, or any other tool
55
+
56
+ ## v0.2 architecture (HTTP-only)
57
+
58
+ In v0.2 the client is a thin HTTP wrapper. No psycopg, no DuckDB, no
59
+ parquet libs — the package installs in seconds and ships only `mcp`,
60
+ `httpx`, and `pydantic`. All data plane operations are server-side at
61
+ `https://almanac.ar/api/mcp/v1/invoke`. Benefits:
62
+
63
+ - Zero credentials on your machine (just `ALMANAC_API_KEY`)
64
+ - Schema discovery + AI metadata live server-side and update without a
65
+ client version bump
66
+ - Telemetry and rate limits centralised
67
+
68
+ ## Requirements
69
+
70
+ - Python 3.10+
71
+ - An Almanac account on plan **Pro+** or **Enterprise** (the MCP server
72
+ is not part of the Pro tier)
73
+ - An API key generated at <https://almanac.ar/account/api-keys>
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ uvx almanac-mcp # try it without installing
79
+ # or
80
+ pip install almanac-mcp # persistent install
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ The client requires `ALMANAC_API_KEY` in the environment. It optionally
86
+ respects `ALMANAC_SITE_URL` (defaults to `https://almanac.ar`).
87
+
88
+ ### Claude Desktop
89
+
90
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
91
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "almanac": {
97
+ "command": "uvx",
98
+ "args": ["almanac-mcp"],
99
+ "env": {
100
+ "ALMANAC_API_KEY": "alm_your_key_here"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Restart Claude Desktop. The Almanac tools appear automatically.
108
+
109
+ ### Cursor
110
+
111
+ `~/.cursor/mcp.json`:
112
+
113
+ ```json
114
+ {
115
+ "mcpServers": {
116
+ "almanac": {
117
+ "command": "uvx",
118
+ "args": ["almanac-mcp"],
119
+ "env": { "ALMANAC_API_KEY": "alm_your_key_here" }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### GitHub Copilot (VS Code)
126
+
127
+ Add to `.vscode/settings.json`:
128
+
129
+ ```json
130
+ {
131
+ "github.copilot.chat.mcp.servers": {
132
+ "almanac": {
133
+ "command": "uvx",
134
+ "args": ["almanac-mcp"],
135
+ "env": { "ALMANAC_API_KEY": "alm_your_key_here" }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ ## Tools
142
+
143
+ All four tools follow the `<domain>.<action>` naming convention:
144
+
145
+ ### `catalog.list`
146
+
147
+ Lists every published dataset with a brief summary. No params.
148
+
149
+ ### `catalog.get(dataset_id)`
150
+
151
+ Full metadata for a single dataset, including:
152
+
153
+ - Description, frequency, historical depth
154
+ - Business questions and typical use cases
155
+ - Methodology notes and known gotchas
156
+ - Pre-built example SQL queries
157
+ - `mcp.table_name` — exact Postgres table (e.g. `data_bcra_monetarias_indicadores`)
158
+ - `mcp.columns` — list of `{name, type, nullable}` to write SQL correctly
159
+
160
+ ### `data.query(sql)`
161
+
162
+ Runs a `SELECT`/`WITH`/`EXPLAIN` query against the production tables.
163
+ DDL/DML are rejected. Server-side constraints:
164
+
165
+ - `statement_timeout` 30 s
166
+ - 10.000 row cap (truncation signalled in response)
167
+ - `work_mem` 32 MB
168
+
169
+ Response shape: `{ rows, row_count, truncated, columns }`. Tables are
170
+ documented through `catalog.get` (`mcp.table_name`).
171
+
172
+ ### `data.snapshot_url(dataset_id, format="parquet")`
173
+
174
+ Returns a 10-minute signed R2 URL to the complete production snapshot
175
+ (parquet or CSV). Use this when you want to download the full dataset
176
+ and work with it locally.
177
+
178
+ ## Example session
179
+
180
+ > User: "What were Argentina's international reserves on the last
181
+ > business day, and how do they compare to a year ago?"
182
+
183
+ The assistant will:
184
+
185
+ 1. Call `catalog.list` to see what is available.
186
+ 2. Call `catalog.get("bcra.monetarias.indicadores")` to find the
187
+ relevant variable and learn the table schema.
188
+ 3. Call `data.query` with something like:
189
+
190
+ ```sql
191
+ SELECT fecha, valor, unidad
192
+ FROM data_bcra_monetarias_indicadores
193
+ WHERE descripcion ILIKE '%reservas internacionales%'
194
+ AND fecha <= CURRENT_DATE
195
+ ORDER BY fecha DESC
196
+ LIMIT 30
197
+ ```
198
+
199
+ 4. Compare the most recent value with the same date a year ago and
200
+ reply with concrete numbers.
201
+
202
+ ## License
203
+
204
+ Proprietary. Use of this package requires an active Almanac subscription
205
+ (plan Pro+ or Enterprise). See <https://almanac.ar/pricing> for details.
206
+
207
+ ## Support
208
+
209
+ - Documentation: <https://almanac.ar/mcp>
210
+ - Issues: <https://github.com/nicolascolombo/Almanac/issues>
211
+ - Email: contacto@almanac.ar
@@ -0,0 +1,179 @@
1
+ # almanac-mcp
2
+
3
+ MCP server for [Almanac](https://almanac.ar) — Argentine financial and
4
+ economic data (BCRA, CNV, INDEC, SEC) exposed directly to your AI
5
+ assistant (Claude Desktop, Cursor, Copilot, etc.) via the Model Context
6
+ Protocol.
7
+
8
+ ## What it does
9
+
10
+ Once connected, your AI assistant can:
11
+
12
+ - **List the catalog** of published datasets (BCRA monetarias,
13
+ tipos de cambio, INDEC IPC, CNV hechos relevantes, SEC EDGAR XBRL
14
+ filings, and more)
15
+ - **Inspect schemas** column-by-column with descriptions, types, and
16
+ pre-built example queries
17
+ - **Run SQL** directly against the production datasets — sub-second
18
+ latency, cross-dataset JOINs work natively (e.g. join INDEC IPC with
19
+ BCRA reservas, or SEC financial facts with CNV hechos relevantes by
20
+ CUIT)
21
+ - **Download full snapshots** as signed parquet/CSV URLs to work locally
22
+ with DuckDB, polars, pandas, R, or any other tool
23
+
24
+ ## v0.2 architecture (HTTP-only)
25
+
26
+ In v0.2 the client is a thin HTTP wrapper. No psycopg, no DuckDB, no
27
+ parquet libs — the package installs in seconds and ships only `mcp`,
28
+ `httpx`, and `pydantic`. All data plane operations are server-side at
29
+ `https://almanac.ar/api/mcp/v1/invoke`. Benefits:
30
+
31
+ - Zero credentials on your machine (just `ALMANAC_API_KEY`)
32
+ - Schema discovery + AI metadata live server-side and update without a
33
+ client version bump
34
+ - Telemetry and rate limits centralised
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.10+
39
+ - An Almanac account on plan **Pro+** or **Enterprise** (the MCP server
40
+ is not part of the Pro tier)
41
+ - An API key generated at <https://almanac.ar/account/api-keys>
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uvx almanac-mcp # try it without installing
47
+ # or
48
+ pip install almanac-mcp # persistent install
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ The client requires `ALMANAC_API_KEY` in the environment. It optionally
54
+ respects `ALMANAC_SITE_URL` (defaults to `https://almanac.ar`).
55
+
56
+ ### Claude Desktop
57
+
58
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
59
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "almanac": {
65
+ "command": "uvx",
66
+ "args": ["almanac-mcp"],
67
+ "env": {
68
+ "ALMANAC_API_KEY": "alm_your_key_here"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Restart Claude Desktop. The Almanac tools appear automatically.
76
+
77
+ ### Cursor
78
+
79
+ `~/.cursor/mcp.json`:
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "almanac": {
85
+ "command": "uvx",
86
+ "args": ["almanac-mcp"],
87
+ "env": { "ALMANAC_API_KEY": "alm_your_key_here" }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ### GitHub Copilot (VS Code)
94
+
95
+ Add to `.vscode/settings.json`:
96
+
97
+ ```json
98
+ {
99
+ "github.copilot.chat.mcp.servers": {
100
+ "almanac": {
101
+ "command": "uvx",
102
+ "args": ["almanac-mcp"],
103
+ "env": { "ALMANAC_API_KEY": "alm_your_key_here" }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ ## Tools
110
+
111
+ All four tools follow the `<domain>.<action>` naming convention:
112
+
113
+ ### `catalog.list`
114
+
115
+ Lists every published dataset with a brief summary. No params.
116
+
117
+ ### `catalog.get(dataset_id)`
118
+
119
+ Full metadata for a single dataset, including:
120
+
121
+ - Description, frequency, historical depth
122
+ - Business questions and typical use cases
123
+ - Methodology notes and known gotchas
124
+ - Pre-built example SQL queries
125
+ - `mcp.table_name` — exact Postgres table (e.g. `data_bcra_monetarias_indicadores`)
126
+ - `mcp.columns` — list of `{name, type, nullable}` to write SQL correctly
127
+
128
+ ### `data.query(sql)`
129
+
130
+ Runs a `SELECT`/`WITH`/`EXPLAIN` query against the production tables.
131
+ DDL/DML are rejected. Server-side constraints:
132
+
133
+ - `statement_timeout` 30 s
134
+ - 10.000 row cap (truncation signalled in response)
135
+ - `work_mem` 32 MB
136
+
137
+ Response shape: `{ rows, row_count, truncated, columns }`. Tables are
138
+ documented through `catalog.get` (`mcp.table_name`).
139
+
140
+ ### `data.snapshot_url(dataset_id, format="parquet")`
141
+
142
+ Returns a 10-minute signed R2 URL to the complete production snapshot
143
+ (parquet or CSV). Use this when you want to download the full dataset
144
+ and work with it locally.
145
+
146
+ ## Example session
147
+
148
+ > User: "What were Argentina's international reserves on the last
149
+ > business day, and how do they compare to a year ago?"
150
+
151
+ The assistant will:
152
+
153
+ 1. Call `catalog.list` to see what is available.
154
+ 2. Call `catalog.get("bcra.monetarias.indicadores")` to find the
155
+ relevant variable and learn the table schema.
156
+ 3. Call `data.query` with something like:
157
+
158
+ ```sql
159
+ SELECT fecha, valor, unidad
160
+ FROM data_bcra_monetarias_indicadores
161
+ WHERE descripcion ILIKE '%reservas internacionales%'
162
+ AND fecha <= CURRENT_DATE
163
+ ORDER BY fecha DESC
164
+ LIMIT 30
165
+ ```
166
+
167
+ 4. Compare the most recent value with the same date a year ago and
168
+ reply with concrete numbers.
169
+
170
+ ## License
171
+
172
+ Proprietary. Use of this package requires an active Almanac subscription
173
+ (plan Pro+ or Enterprise). See <https://almanac.ar/pricing> for details.
174
+
175
+ ## Support
176
+
177
+ - Documentation: <https://almanac.ar/mcp>
178
+ - Issues: <https://github.com/nicolascolombo/Almanac/issues>
179
+ - Email: contacto@almanac.ar
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "almanac-mcp"
3
+ version = "0.2.0"
4
+ description = "MCP server for Almanac — Argentine financial data (BCRA, CNV, INDEC, SEC) for Claude Desktop, Cursor, Copilot"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "Nicolás Colombo" }]
8
+ license = { text = "Proprietary" }
9
+ keywords = ["mcp", "almanac", "bcra", "cnv", "indec", "sec", "argentina", "financial-data", "claude"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Financial and Insurance Industry",
13
+ "License :: Other/Proprietary License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Office/Business :: Financial",
21
+ "Topic :: Scientific/Engineering",
22
+ ]
23
+
24
+ dependencies = [
25
+ "mcp>=1.0",
26
+ "httpx>=0.27",
27
+ "pydantic>=2.8",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://almanac.ar"
32
+ Documentation = "https://almanac.ar/mcp"
33
+ Source = "https://github.com/nicolascolombo/Almanac"
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.3",
38
+ "pytest-asyncio>=0.24",
39
+ "respx>=0.21",
40
+ "ruff>=0.7",
41
+ ]
42
+
43
+ [project.scripts]
44
+ almanac-mcp = "almanac_mcp.__main__:main"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/almanac_mcp"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = [
55
+ "src/almanac_mcp",
56
+ "README.md",
57
+ "pyproject.toml",
58
+ ]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "B", "UP"]
@@ -0,0 +1,3 @@
1
+ """MCP server for the Almanac argentino."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,14 @@
1
+ """Entrypoint: arranca el MCP server por stdio (modo Claude Desktop / Cursor)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from almanac_mcp.server import build_server
6
+
7
+
8
+ def main() -> None:
9
+ server = build_server()
10
+ server.run()
11
+
12
+
13
+ if __name__ == "__main__":
14
+ main()
@@ -0,0 +1,195 @@
1
+ """Cliente HTTP del MCP server. Habla con /api/mcp/v1/invoke.
2
+
3
+ Funcionalidad: invoca tools por nombre, maneja retries con backoff
4
+ exponencial para 5xx (server transient), no retry para 4xx (input
5
+ inválido del agente), traduce errores estructurados a excepciones
6
+ Python pythónicas.
7
+
8
+ Tipos de error:
9
+ InvalidParams → el agente mandó params malformados; corregir SQL/args
10
+ ToolUnknown → tool name no existe en el server (versión mismatch)
11
+ NotFound → dataset/snapshot no existe
12
+ TierRequired → API key con tier insuficiente (Pro+ requerido)
13
+ RateLimited → 429, esperar y reintentar
14
+ Timeout → query > 30s, refinar
15
+ InternalError → 500 del server (poco común)
16
+ NetworkError → connection refused / DNS / etc.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ import httpx
28
+
29
+ from almanac_mcp.settings import Settings
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ USER_AGENT = "almanac-mcp/0.2.0 (+https://almanac.ar/mcp)"
34
+ REQUEST_TIMEOUT_S = 60.0
35
+ MAX_RETRIES = 3
36
+ RETRY_BASE_DELAY_S = 0.5
37
+
38
+
39
+ class MCPClientError(Exception):
40
+ """Base de errors del client."""
41
+
42
+ code: str
43
+
44
+ def __init__(self, message: str, code: str = "unknown", details: dict[str, Any] | None = None):
45
+ super().__init__(message)
46
+ self.code = code
47
+ self.details = details or {}
48
+
49
+
50
+ class InvalidParams(MCPClientError):
51
+ code = "invalid_params"
52
+
53
+
54
+ class ToolUnknown(MCPClientError):
55
+ code = "tool_unknown"
56
+
57
+
58
+ class NotFound(MCPClientError):
59
+ code = "not_found"
60
+
61
+
62
+ class TierRequired(MCPClientError):
63
+ code = "tier_required"
64
+
65
+
66
+ class RateLimited(MCPClientError):
67
+ code = "rate_limited"
68
+
69
+
70
+ class TimeoutError(MCPClientError): # noqa: A001
71
+ code = "timeout"
72
+
73
+
74
+ class InternalError(MCPClientError):
75
+ code = "internal"
76
+
77
+
78
+ class NetworkError(MCPClientError):
79
+ code = "network"
80
+
81
+
82
+ _ERROR_BY_CODE: dict[str, type[MCPClientError]] = {
83
+ "invalid_params": InvalidParams,
84
+ "tool_unknown": ToolUnknown,
85
+ "not_found": NotFound,
86
+ "tier_required": TierRequired,
87
+ "rate_limited": RateLimited,
88
+ "timeout": TimeoutError,
89
+ "internal": InternalError,
90
+ }
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class InvokeResult:
95
+ result: Any
96
+ request_id: str
97
+ latency_ms: int
98
+
99
+
100
+ class MCPHttpClient:
101
+ """Cliente HTTP del MCP server. Singleton por proceso típicamente."""
102
+
103
+ def __init__(self, settings: Settings, *, transport: httpx.BaseTransport | None = None):
104
+ self.settings = settings
105
+ self._http = httpx.Client(
106
+ base_url=settings.site_url,
107
+ timeout=REQUEST_TIMEOUT_S,
108
+ headers={
109
+ "Authorization": f"Bearer {settings.api_key}",
110
+ "User-Agent": USER_AGENT,
111
+ "Content-Type": "application/json",
112
+ },
113
+ transport=transport,
114
+ )
115
+
116
+ def close(self) -> None:
117
+ self._http.close()
118
+
119
+ def __enter__(self) -> MCPHttpClient:
120
+ return self
121
+
122
+ def __exit__(self, *_exc_info: Any) -> None:
123
+ self.close()
124
+
125
+ def invoke(
126
+ self,
127
+ tool: str,
128
+ params: dict[str, Any] | None = None,
129
+ *,
130
+ client_info: dict[str, str] | None = None,
131
+ ) -> InvokeResult:
132
+ request_id = str(uuid.uuid4())
133
+ body: dict[str, Any] = {"tool": tool, "request_id": request_id}
134
+ if params is not None:
135
+ body["params"] = params
136
+ if client_info:
137
+ body["client_info"] = client_info
138
+
139
+ last_exc: Exception | None = None
140
+ for attempt in range(MAX_RETRIES):
141
+ try:
142
+ resp = self._http.post("/api/mcp/v1/invoke", json=body)
143
+ except httpx.RequestError as e:
144
+ last_exc = e
145
+ if attempt < MAX_RETRIES - 1:
146
+ self._sleep_backoff(attempt)
147
+ continue
148
+ raise NetworkError(f"Error de red contra Almanac: {e}") from e
149
+
150
+ # 5xx → retry; 4xx → fail immediate; 2xx → parse
151
+ if resp.status_code >= 500:
152
+ last_exc = httpx.HTTPStatusError(
153
+ f"Server {resp.status_code}", request=resp.request, response=resp
154
+ )
155
+ if attempt < MAX_RETRIES - 1:
156
+ self._sleep_backoff(attempt)
157
+ continue
158
+ # Out of retries, fall through to parse the error body.
159
+
160
+ return self._parse_response(resp, request_id)
161
+
162
+ # Solo llegamos acá si NetworkError no relanzó (no debería pasar).
163
+ raise NetworkError(f"Sin respuesta del server tras {MAX_RETRIES} intentos: {last_exc}")
164
+
165
+ @staticmethod
166
+ def _sleep_backoff(attempt: int) -> None:
167
+ delay = RETRY_BASE_DELAY_S * (2**attempt)
168
+ log.debug("mcp_client.retry", extra={"attempt": attempt, "delay_s": delay})
169
+ time.sleep(delay)
170
+
171
+ @staticmethod
172
+ def _parse_response(resp: httpx.Response, request_id: str) -> InvokeResult:
173
+ try:
174
+ payload = resp.json()
175
+ except ValueError as e:
176
+ raise InternalError(
177
+ f"Server devolvió body no-JSON (status {resp.status_code}): {resp.text[:200]}",
178
+ ) from e
179
+
180
+ latency_ms = int(payload.get("latency_ms") or 0)
181
+ server_request_id = payload.get("request_id") or request_id
182
+
183
+ if payload.get("ok") is True:
184
+ return InvokeResult(
185
+ result=payload.get("result"),
186
+ request_id=server_request_id,
187
+ latency_ms=latency_ms,
188
+ )
189
+
190
+ error = payload.get("error") or {}
191
+ code = error.get("code") or "internal"
192
+ message = error.get("message") or f"Server error {resp.status_code}"
193
+ details = error.get("details") or {}
194
+ exc_cls = _ERROR_BY_CODE.get(code, InternalError)
195
+ raise exc_cls(message, code=code, details=details)
@@ -0,0 +1,131 @@
1
+ """FastMCP server v0.2 — delega todo al endpoint HTTP /api/mcp/v1/invoke.
2
+
3
+ Las 4 tools v0.2 con dotted naming (`<dominio>.<acción>`):
4
+ catalog.list → lista datasets publicados
5
+ catalog.get → metadata completa + schema + example_queries
6
+ data.snapshot_url → signed URL al snapshot productivo completo (R2)
7
+ data.query → ejecuta SQL contra Postgres data_* (datos completos)
8
+
9
+ El client Python ya NO toca Postgres ni R2 directo. Todo el compute y
10
+ security pasa por el web server. Beneficios:
11
+ - Cero credenciales en el cliente (solo ALMANAC_API_KEY)
12
+ - Schema discovery + AI metadata viven server-side y se actualizan sin
13
+ bump de versión del client
14
+ - Telemetry centralizado en mcp_invocations
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import platform
21
+ from typing import Any
22
+
23
+ from mcp.server.fastmcp import FastMCP
24
+
25
+ from almanac_mcp import __version__
26
+ from almanac_mcp.client import MCPHttpClient
27
+ from almanac_mcp.settings import Settings
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ def _client_info() -> dict[str, str]:
33
+ """Metadata del client para telemetry server-side."""
34
+ return {
35
+ "client": "almanac-mcp",
36
+ "version": __version__,
37
+ "os": platform.system().lower(),
38
+ }
39
+
40
+
41
+ def build_server(settings: Settings | None = None) -> FastMCP:
42
+ """Construye el server MCP con las 4 tools registradas."""
43
+ if settings is None:
44
+ settings = Settings.from_env()
45
+
46
+ mcp = FastMCP("almanac")
47
+ client = MCPHttpClient(settings)
48
+
49
+ @mcp.tool(name="catalog.list")
50
+ def catalog_list() -> list[dict[str, Any]]:
51
+ """Lista los datasets publicados del Almanac (BCRA, CNV, INDEC, SEC, etc).
52
+
53
+ Cada item trae: dataset_id, name, source, description, frequency,
54
+ historical_depth. Para metadata completa + schema de columnas + queries
55
+ de ejemplo, usar catalog.get(dataset_id).
56
+ """
57
+ return client.invoke("catalog.list", {}, client_info=_client_info()).result
58
+
59
+ @mcp.tool(name="catalog.get")
60
+ def catalog_get(dataset_id: str) -> dict[str, Any]:
61
+ """Devuelve metadata completa + schema de la tabla Postgres del dataset.
62
+
63
+ Incluye:
64
+ - Descripción + business_questions + methodology_notes + gotchas
65
+ - example_queries (templates SQL listos para usar)
66
+ - mcp.table_name: nombre exacto de la tabla en Postgres (data_*)
67
+ - mcp.columns: lista de (name, type, nullable) para armar SQL
68
+ - mcp.queryable_via_sql: bool — si False, usar data.snapshot_url
69
+
70
+ Args:
71
+ dataset_id: Identificador formato {fuente}.{categoria}.{nombre},
72
+ ej: 'bcra.monetarias.indicadores'.
73
+ """
74
+ return client.invoke(
75
+ "catalog.get",
76
+ {"dataset_id": dataset_id},
77
+ client_info=_client_info(),
78
+ ).result
79
+
80
+ @mcp.tool(name="data.snapshot_url")
81
+ def data_snapshot_url(dataset_id: str, format: str = "parquet") -> dict[str, Any]:
82
+ """Devuelve una URL firmada R2 de 10 minutos al snapshot productivo completo.
83
+
84
+ Útil cuando el cliente quiere bajar el dataset completo y trabajarlo
85
+ localmente (DuckDB, polars, pandas, R, etc). Para queries SQL rápidas
86
+ sobre los datos completos sin descarga, usar `data.query`.
87
+
88
+ Args:
89
+ dataset_id: Identificador del dataset.
90
+ format: 'parquet' (recomendado) o 'csv'.
91
+
92
+ Returns:
93
+ { url, expires_in_seconds, dataset_id, format,
94
+ snapshot: { snapshot_at, rows_count, file_size_bytes } }
95
+ """
96
+ return client.invoke(
97
+ "data.snapshot_url",
98
+ {"dataset_id": dataset_id, "format": format},
99
+ client_info=_client_info(),
100
+ ).result
101
+
102
+ @mcp.tool(name="data.query")
103
+ def data_query(sql: str) -> dict[str, Any]:
104
+ """Ejecuta una query SQL SELECT/WITH/EXPLAIN sobre las tablas de Almanac.
105
+
106
+ Las tablas disponibles tienen prefijo `data_*` (ver catalog.get →
107
+ mcp.table_name por dataset). Soporta JOINs cross-dataset entre BCRA,
108
+ INDEC, CNV, SEC. Solo lectura — DDL/DML rechazados.
109
+
110
+ Constraints server-side:
111
+ - statement_timeout: 30s
112
+ - cap de filas: 10.000 (truncated=true si excede)
113
+ - work_mem: 32 MB
114
+
115
+ Args:
116
+ sql: Query SQL válida. Ej:
117
+ "SELECT fecha, valor FROM data_bcra_monetarias_indicadores
118
+ WHERE id_variable = 1 AND fecha >= '2024-01-01'
119
+ ORDER BY fecha DESC LIMIT 100"
120
+
121
+ Returns:
122
+ { rows: list[dict], row_count: int, truncated: bool,
123
+ columns: list[str] }
124
+ """
125
+ return client.invoke(
126
+ "data.query",
127
+ {"sql": sql},
128
+ client_info=_client_info(),
129
+ ).result
130
+
131
+ return mcp
@@ -0,0 +1,41 @@
1
+ """Configuración del MCP client v0.2.
2
+
3
+ v0.2 cambia el modelo: en lugar de pegarle directo a Postgres / R2,
4
+ todas las tools delegan al endpoint HTTP /api/mcp/v1/invoke del web
5
+ de Almanac. El client solo necesita URL del site + API key del user.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Settings:
16
+ """Configuración inmutable del client. Sin I/O en __init__."""
17
+
18
+ site_url: str
19
+ api_key: str
20
+
21
+ @classmethod
22
+ def from_env(cls) -> Settings:
23
+ api_key = os.environ.get("ALMANAC_API_KEY", "").strip()
24
+ if not api_key:
25
+ raise RuntimeError(
26
+ "ALMANAC_API_KEY no está definida. Generá una key en "
27
+ "https://almanac.ar/account/api-keys (requiere plan Pro+ o Enterprise) "
28
+ "y configurala en el env del cliente MCP."
29
+ )
30
+ if not api_key.startswith("alm_"):
31
+ raise RuntimeError(
32
+ "ALMANAC_API_KEY tiene formato inválido. Las keys de Almanac "
33
+ "arrancan con 'alm_'. Revisá tu config."
34
+ )
35
+ site_url = os.environ.get("ALMANAC_SITE_URL", "https://almanac.ar").rstrip("/")
36
+ return cls(site_url=site_url, api_key=api_key)
37
+
38
+ @property
39
+ def invoke_url(self) -> str:
40
+ """URL del endpoint multiplexor JSON del web server."""
41
+ return f"{self.site_url}/api/mcp/v1/invoke"