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.
- almanac_mcp-0.2.0/.gitignore +96 -0
- almanac_mcp-0.2.0/PKG-INFO +211 -0
- almanac_mcp-0.2.0/README.md +179 -0
- almanac_mcp-0.2.0/pyproject.toml +65 -0
- almanac_mcp-0.2.0/src/almanac_mcp/__init__.py +3 -0
- almanac_mcp-0.2.0/src/almanac_mcp/__main__.py +14 -0
- almanac_mcp-0.2.0/src/almanac_mcp/client.py +195 -0
- almanac_mcp-0.2.0/src/almanac_mcp/server.py +131 -0
- almanac_mcp-0.2.0/src/almanac_mcp/settings.py +41 -0
|
@@ -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,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"
|