meryn-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meryn_mcp-0.1.0/.gitignore +153 -0
- meryn_mcp-0.1.0/PKG-INFO +138 -0
- meryn_mcp-0.1.0/README.md +120 -0
- meryn_mcp-0.1.0/meryn_mcp/__init__.py +663 -0
- meryn_mcp-0.1.0/meryn_mcp/servidor.py +107 -0
- meryn_mcp-0.1.0/pyproject.toml +42 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# docs/ (SOLO la de la raíz) entero: notas de trabajo, no fuente. Estaban listados
|
|
2
|
+
# de a uno (schema, MASTER_PLAN, TESTS_SPEC…) y cada archivo nuevo se colaba al
|
|
3
|
+
# repo por olvido. Ojo: CLAUDE.md apunta a docs/db-region-y-latencia.md y dos
|
|
4
|
+
# tests citan docs/{auditoria-2026-08,db-region-y-latencia}.md — en un clon
|
|
5
|
+
# limpio esos punteros no resuelven. El contenido sigue en el historial.
|
|
6
|
+
# Ancla a la raíz con `/`: sin eso, el patrón también se comía apps/docs/,
|
|
7
|
+
# content/docs/ y static/docs/img/ — la documentación de producto, que SÍ es
|
|
8
|
+
# fuente y tiene que commitearse.
|
|
9
|
+
/docs
|
|
10
|
+
|
|
11
|
+
.idea
|
|
12
|
+
.vscode
|
|
13
|
+
*.code-workspace
|
|
14
|
+
*.pyc
|
|
15
|
+
*~
|
|
16
|
+
.venv
|
|
17
|
+
venv
|
|
18
|
+
db.sqlite3
|
|
19
|
+
.sass-cache/
|
|
20
|
+
celerybeat-schedule
|
|
21
|
+
mktlab/localsettings.py
|
|
22
|
+
mktlab/local.py
|
|
23
|
+
static_root/
|
|
24
|
+
node_modules/
|
|
25
|
+
.vite/
|
|
26
|
+
openapitools.json
|
|
27
|
+
media/
|
|
28
|
+
_dist/
|
|
29
|
+
.env
|
|
30
|
+
.env.production
|
|
31
|
+
.DS_Store
|
|
32
|
+
.direnv
|
|
33
|
+
.envrc
|
|
34
|
+
# compiled static files are built in dev/deploy
|
|
35
|
+
static/css/
|
|
36
|
+
static/js/*
|
|
37
|
+
# fuentes y demás assets que emite Vite con hash en el nombre
|
|
38
|
+
static/assets/
|
|
39
|
+
# ...except the hand-written canvas JS, which is source, not a Vite build output
|
|
40
|
+
!static/js/canvas/
|
|
41
|
+
# Las librerías de terceros las copia `npm run vendor` desde node_modules
|
|
42
|
+
# (scripts/vendor-static.mjs); son build output, no se commitean.
|
|
43
|
+
static/js/vendor/
|
|
44
|
+
|
|
45
|
+
# Environment files with credentials
|
|
46
|
+
.env.local
|
|
47
|
+
.env.production
|
|
48
|
+
.env.staging
|
|
49
|
+
.env.development
|
|
50
|
+
|
|
51
|
+
# Credential files
|
|
52
|
+
credentials/
|
|
53
|
+
secrets/
|
|
54
|
+
*.key
|
|
55
|
+
*.pem
|
|
56
|
+
service-account*.json
|
|
57
|
+
*credential*.json
|
|
58
|
+
*secret*.json
|
|
59
|
+
|
|
60
|
+
# Database files
|
|
61
|
+
*.db
|
|
62
|
+
*.sqlite3
|
|
63
|
+
|
|
64
|
+
# Log files
|
|
65
|
+
*.log
|
|
66
|
+
logs.json
|
|
67
|
+
logs/
|
|
68
|
+
*.log.*
|
|
69
|
+
|
|
70
|
+
# Temporary files
|
|
71
|
+
*.tmp
|
|
72
|
+
*.temp
|
|
73
|
+
*.bak
|
|
74
|
+
*.backup
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Pendings
|
|
78
|
+
zzz.txt
|
|
79
|
+
|
|
80
|
+
# Volcados de logs de los workers. El script que los genera vive en
|
|
81
|
+
# scripts/fetch_worker_logs.sh y SÍ se versiona; su salida, no.
|
|
82
|
+
*_logs.txt
|
|
83
|
+
|
|
84
|
+
# R API folder
|
|
85
|
+
apiR/
|
|
86
|
+
|
|
87
|
+
# Robyn local development files
|
|
88
|
+
robyn.csv
|
|
89
|
+
tmp/robyn_outputs/
|
|
90
|
+
*.csv.tmp
|
|
91
|
+
|
|
92
|
+
# Charts Svelte
|
|
93
|
+
charts/
|
|
94
|
+
!templates/charts/
|
|
95
|
+
|
|
96
|
+
# Development tools cache and config
|
|
97
|
+
# `.claude/skills/` SÍ va a git: CLAUDE.md las referencia por ruta, así que si no
|
|
98
|
+
# viajan, un clone fresco tiene punteros rotos. Se excluyen dos cosas y sólo dos:
|
|
99
|
+
# - settings.local.json: permisos de ESTA máquina, no del proyecto.
|
|
100
|
+
# - worktrees/: son git worktrees (cada uno con su propio `.git`), así que git
|
|
101
|
+
# los ve como repos EMBEBIDOS. No se llevaría los 2,8 GB —git no recursa en un
|
|
102
|
+
# repo anidado— pero los agregaría como gitlink: una entrada de submódulo que
|
|
103
|
+
# apunta a un commit que nadie puede clonar, y rompe el checkout de quien clone.
|
|
104
|
+
.claude/settings.local.json
|
|
105
|
+
.claude/worktrees/
|
|
106
|
+
.ruff_cache/
|
|
107
|
+
.clinerules
|
|
108
|
+
|
|
109
|
+
# Teams
|
|
110
|
+
teams.txt
|
|
111
|
+
pegasus_example_team_apps/
|
|
112
|
+
|
|
113
|
+
# Stripe
|
|
114
|
+
stripe.md
|
|
115
|
+
|
|
116
|
+
# Robyn hyperparameters default
|
|
117
|
+
robyn_hyperparameter_default.ods
|
|
118
|
+
|
|
119
|
+
# Table names
|
|
120
|
+
TABLA_RENAMING_PLAN.md
|
|
121
|
+
|
|
122
|
+
# Meridian Documentation
|
|
123
|
+
meridian_documentation
|
|
124
|
+
|
|
125
|
+
# Meridian plots (local reference only, not for GitHub)
|
|
126
|
+
plots_meridian/
|
|
127
|
+
|
|
128
|
+
# Claude Ignore
|
|
129
|
+
.claudeignore
|
|
130
|
+
|
|
131
|
+
# EDA
|
|
132
|
+
eda.py
|
|
133
|
+
|
|
134
|
+
# Log del worker local (make demo)
|
|
135
|
+
.demo-worker.log
|
|
136
|
+
|
|
137
|
+
# Artefactos del barrido de QA (scripts/qa/) — snapshots, capturas y reportes
|
|
138
|
+
qa-runs/
|
|
139
|
+
|
|
140
|
+
# Documentación y notebooks de los frameworks MMM, pineados a la versión que
|
|
141
|
+
# corre en prod. Los baja `scripts/sync_framework_docs.py` desde los repos
|
|
142
|
+
# upstream: son ~320 MB reconstruibles, igual que static/js/vendor/.
|
|
143
|
+
# El índice de qué archivo contesta qué pregunta SÍ va trackeado.
|
|
144
|
+
# ⚠️ `reference/*` y no `reference/`: git no re-incluye un archivo si el
|
|
145
|
+
# directorio padre está excluido — la negación no falla, simplemente no hace nada.
|
|
146
|
+
reference/*
|
|
147
|
+
!reference/INDEX.md
|
|
148
|
+
|
|
149
|
+
# La lista de prospectos de `scripts/prospectos.py`: nombres, emails y empleadores de
|
|
150
|
+
# gente real, sacados de sus perfiles públicos de GitHub. No es fuente y no queremos
|
|
151
|
+
# datos personales en git — menos todavía en un clon ajeno.
|
|
152
|
+
prospectos.csv
|
|
153
|
+
prospectos.log
|
meryn_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: meryn-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the meryn API — read your MMM results and launch runs from Claude
|
|
5
|
+
Project-URL: Homepage, https://meryn.ai
|
|
6
|
+
Project-URL: Documentation, https://meryn.ai/docs/api/mcp/
|
|
7
|
+
Project-URL: API reference, https://meryn.ai/api/v1/docs/
|
|
8
|
+
Author-email: meryn <hi@meryn.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: marketing-mix-modeling,mcp,meridian,mmm,pymc-marketing,robyn
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: mcp<3,>=2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# meryn MCP
|
|
20
|
+
|
|
21
|
+
Connect Claude to your [meryn](https://meryn.ai) account: read your MMM results and launch
|
|
22
|
+
runs without leaving the conversation.
|
|
23
|
+
|
|
24
|
+
The server runs **on your machine**, talks to the meryn API over HTTPS, and holds nothing —
|
|
25
|
+
your key lives in your own config.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude mcp add meryn -e MERYN_API_KEY=mk_… -- uvx meryn-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or, in any MCP client's config file:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"meryn": {
|
|
39
|
+
"command": "uvx",
|
|
40
|
+
"args": ["meryn-mcp"],
|
|
41
|
+
"env": {
|
|
42
|
+
"MERYN_API_KEY": "mk_...",
|
|
43
|
+
"MERYN_BASE_URL": "https://meryn.ai"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`MERYN_BASE_URL` is optional; it defaults to `https://meryn.ai`.
|
|
51
|
+
|
|
52
|
+
**The key** is the one you create in your team's settings → **API keys**
|
|
53
|
+
([how](https://meryn.ai/docs/api/api-keys/)). It is shown once, so copy it then. Give it
|
|
54
|
+
`read` unless you want the assistant to be able to launch runs.
|
|
55
|
+
|
|
56
|
+
⚠️ **API access is part of the plan.** Professional and Enterprise include it; Free does
|
|
57
|
+
not. Without it the first tool call returns
|
|
58
|
+
`403: This team plan does not include API access.`
|
|
59
|
+
|
|
60
|
+
## What it can do
|
|
61
|
+
|
|
62
|
+
| | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **Read** | projects, datasets, mappings and periods; models with their metrics and readiness verdict; the progress, logs and run history; every chart dataset and the finer-grained result tables; EDA runs; the budget allocator's split by channel and by market, and its efficiency |
|
|
65
|
+
| **Launch** | create a model on an existing period, run it, cancel it; create and run a budget optimisation |
|
|
66
|
+
|
|
67
|
+
What it deliberately **doesn't** do: upload and map a CSV. That step stays in the web app,
|
|
68
|
+
where a person confirms how the file was interpreted — which column is the date, what each
|
|
69
|
+
column means, what got normalised.
|
|
70
|
+
|
|
71
|
+
## Three things worth knowing
|
|
72
|
+
|
|
73
|
+
**The knobs aren't written into the server.** `list_engine_params` fetches them live from
|
|
74
|
+
the API, with each one's type, default, bounds and help text. They're the same rows that
|
|
75
|
+
draw the modals in the app, so they can't go stale — and it's the first place to look
|
|
76
|
+
before building a `config`.
|
|
77
|
+
|
|
78
|
+
**Anything that costs compute asks first.** `run_model` and `run_allocator` start
|
|
79
|
+
containers: tens of minutes of compute and one of your plan's concurrent-run slots. The
|
|
80
|
+
server **refuses** them without an explicit `confirm`, so an assistant can't fire one in
|
|
81
|
+
passing. That's a guard in the code, not a hint in the description.
|
|
82
|
+
|
|
83
|
+
**Retries don't duplicate runs.** Every POST carries an `Idempotency-Key` derived from the
|
|
84
|
+
operation and its arguments, so if the assistant retries because it didn't see the
|
|
85
|
+
response, the API returns what it already created instead of launching a second run.
|
|
86
|
+
|
|
87
|
+
## How to read the numbers
|
|
88
|
+
|
|
89
|
+
The API returns **raw** values with a `units` map beside them, and that is on purpose: an
|
|
90
|
+
assistant doing arithmetic on `"12.3%"` gets it wrong two steps later, in a budget
|
|
91
|
+
recommendation. Four rules that matter:
|
|
92
|
+
|
|
93
|
+
- `fraction` is 0–1 and `percent` is 0–100 — both appear in the same response, so read the
|
|
94
|
+
unit instead of guessing from the field name;
|
|
95
|
+
- a `null` means *not measured*, never zero — an organic channel's ROI is `null` because it
|
|
96
|
+
has no spend, and averaging it as zero drags the average down;
|
|
97
|
+
- anything listed in `not_applicable` **does not exist** for that engine (Robyn does no
|
|
98
|
+
MCMC, so it has no r-hat). That is not missing data;
|
|
99
|
+
- on the readiness verdict, `pending` and `not_evaluated` are **not** green: the first means
|
|
100
|
+
it hasn't been computed yet, the second that it ran and could not measure anything.
|
|
101
|
+
|
|
102
|
+
⚠️ **And on charts, watch `national_only`.** When it is set, the rows are **national**, not
|
|
103
|
+
the market you asked for — either because that chart cannot be cut by market (Meridian
|
|
104
|
+
estimates one decay per channel, shared across markets) or because the model was trained
|
|
105
|
+
before we started saving it per market. Reporting them as that market's is the mistake the
|
|
106
|
+
field exists to prevent.
|
|
107
|
+
|
|
108
|
+
## Not hosted anywhere
|
|
109
|
+
|
|
110
|
+
There is no remote MCP endpoint to point at, and that is deliberate for now: stdio on your
|
|
111
|
+
own machine means the key never leaves it and there is no third party in the path. A hosted
|
|
112
|
+
version with OAuth is something we will build when someone needs it.
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- [Using the API](https://meryn.ai/docs/api/using-the-api/) — authentication, pagination,
|
|
117
|
+
retries, and the same four rules in more detail
|
|
118
|
+
- [API reference](https://meryn.ai/api/v1/docs/) — generated from the API itself
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
<!-- Lo de abajo es para quien trabaja en el repo, no para quien instala el paquete; va
|
|
123
|
+
acá porque este README ES la página de PyPI y partirlo en dos archivos deja uno de
|
|
124
|
+
los dos sin leer. -->
|
|
125
|
+
|
|
126
|
+
### Para el repo
|
|
127
|
+
|
|
128
|
+
| Archivo | Qué es |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `meryn_mcp/__init__.py` | el cliente HTTP y el catálogo de tools. **Sin dependencias**, y es lo que prueba la suite (`apps/api/tests/test_mcp.py`) |
|
|
131
|
+
| `meryn_mcp/servidor.py` | el cableado del SDK de MCP. Lo mínimo que no se puede probar sin el paquete `mcp` |
|
|
132
|
+
|
|
133
|
+
⚠️ **Es un paquete y no dos módulos sueltos**, y el motivo aparece recién al publicar: el
|
|
134
|
+
wheel dejaba un top-level llamado `servidor` en el `site-packages` de cualquiera que lo
|
|
135
|
+
instalara. Adentro del repo daba igual; distribuido, no.
|
|
136
|
+
|
|
137
|
+
Publicar: `uv build && uv publish` desde `clients/mcp/`. ⚠️ Subir la versión en
|
|
138
|
+
`pyproject.toml` antes — PyPI no deja re-subir una versión ya publicada.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# meryn MCP
|
|
2
|
+
|
|
3
|
+
Connect Claude to your [meryn](https://meryn.ai) account: read your MMM results and launch
|
|
4
|
+
runs without leaving the conversation.
|
|
5
|
+
|
|
6
|
+
The server runs **on your machine**, talks to the meryn API over HTTPS, and holds nothing —
|
|
7
|
+
your key lives in your own config.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add meryn -e MERYN_API_KEY=mk_… -- uvx meryn-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or, in any MCP client's config file:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"meryn": {
|
|
21
|
+
"command": "uvx",
|
|
22
|
+
"args": ["meryn-mcp"],
|
|
23
|
+
"env": {
|
|
24
|
+
"MERYN_API_KEY": "mk_...",
|
|
25
|
+
"MERYN_BASE_URL": "https://meryn.ai"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`MERYN_BASE_URL` is optional; it defaults to `https://meryn.ai`.
|
|
33
|
+
|
|
34
|
+
**The key** is the one you create in your team's settings → **API keys**
|
|
35
|
+
([how](https://meryn.ai/docs/api/api-keys/)). It is shown once, so copy it then. Give it
|
|
36
|
+
`read` unless you want the assistant to be able to launch runs.
|
|
37
|
+
|
|
38
|
+
⚠️ **API access is part of the plan.** Professional and Enterprise include it; Free does
|
|
39
|
+
not. Without it the first tool call returns
|
|
40
|
+
`403: This team plan does not include API access.`
|
|
41
|
+
|
|
42
|
+
## What it can do
|
|
43
|
+
|
|
44
|
+
| | |
|
|
45
|
+
|---|---|
|
|
46
|
+
| **Read** | projects, datasets, mappings and periods; models with their metrics and readiness verdict; the progress, logs and run history; every chart dataset and the finer-grained result tables; EDA runs; the budget allocator's split by channel and by market, and its efficiency |
|
|
47
|
+
| **Launch** | create a model on an existing period, run it, cancel it; create and run a budget optimisation |
|
|
48
|
+
|
|
49
|
+
What it deliberately **doesn't** do: upload and map a CSV. That step stays in the web app,
|
|
50
|
+
where a person confirms how the file was interpreted — which column is the date, what each
|
|
51
|
+
column means, what got normalised.
|
|
52
|
+
|
|
53
|
+
## Three things worth knowing
|
|
54
|
+
|
|
55
|
+
**The knobs aren't written into the server.** `list_engine_params` fetches them live from
|
|
56
|
+
the API, with each one's type, default, bounds and help text. They're the same rows that
|
|
57
|
+
draw the modals in the app, so they can't go stale — and it's the first place to look
|
|
58
|
+
before building a `config`.
|
|
59
|
+
|
|
60
|
+
**Anything that costs compute asks first.** `run_model` and `run_allocator` start
|
|
61
|
+
containers: tens of minutes of compute and one of your plan's concurrent-run slots. The
|
|
62
|
+
server **refuses** them without an explicit `confirm`, so an assistant can't fire one in
|
|
63
|
+
passing. That's a guard in the code, not a hint in the description.
|
|
64
|
+
|
|
65
|
+
**Retries don't duplicate runs.** Every POST carries an `Idempotency-Key` derived from the
|
|
66
|
+
operation and its arguments, so if the assistant retries because it didn't see the
|
|
67
|
+
response, the API returns what it already created instead of launching a second run.
|
|
68
|
+
|
|
69
|
+
## How to read the numbers
|
|
70
|
+
|
|
71
|
+
The API returns **raw** values with a `units` map beside them, and that is on purpose: an
|
|
72
|
+
assistant doing arithmetic on `"12.3%"` gets it wrong two steps later, in a budget
|
|
73
|
+
recommendation. Four rules that matter:
|
|
74
|
+
|
|
75
|
+
- `fraction` is 0–1 and `percent` is 0–100 — both appear in the same response, so read the
|
|
76
|
+
unit instead of guessing from the field name;
|
|
77
|
+
- a `null` means *not measured*, never zero — an organic channel's ROI is `null` because it
|
|
78
|
+
has no spend, and averaging it as zero drags the average down;
|
|
79
|
+
- anything listed in `not_applicable` **does not exist** for that engine (Robyn does no
|
|
80
|
+
MCMC, so it has no r-hat). That is not missing data;
|
|
81
|
+
- on the readiness verdict, `pending` and `not_evaluated` are **not** green: the first means
|
|
82
|
+
it hasn't been computed yet, the second that it ran and could not measure anything.
|
|
83
|
+
|
|
84
|
+
⚠️ **And on charts, watch `national_only`.** When it is set, the rows are **national**, not
|
|
85
|
+
the market you asked for — either because that chart cannot be cut by market (Meridian
|
|
86
|
+
estimates one decay per channel, shared across markets) or because the model was trained
|
|
87
|
+
before we started saving it per market. Reporting them as that market's is the mistake the
|
|
88
|
+
field exists to prevent.
|
|
89
|
+
|
|
90
|
+
## Not hosted anywhere
|
|
91
|
+
|
|
92
|
+
There is no remote MCP endpoint to point at, and that is deliberate for now: stdio on your
|
|
93
|
+
own machine means the key never leaves it and there is no third party in the path. A hosted
|
|
94
|
+
version with OAuth is something we will build when someone needs it.
|
|
95
|
+
|
|
96
|
+
## Links
|
|
97
|
+
|
|
98
|
+
- [Using the API](https://meryn.ai/docs/api/using-the-api/) — authentication, pagination,
|
|
99
|
+
retries, and the same four rules in more detail
|
|
100
|
+
- [API reference](https://meryn.ai/api/v1/docs/) — generated from the API itself
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
<!-- Lo de abajo es para quien trabaja en el repo, no para quien instala el paquete; va
|
|
105
|
+
acá porque este README ES la página de PyPI y partirlo en dos archivos deja uno de
|
|
106
|
+
los dos sin leer. -->
|
|
107
|
+
|
|
108
|
+
### Para el repo
|
|
109
|
+
|
|
110
|
+
| Archivo | Qué es |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `meryn_mcp/__init__.py` | el cliente HTTP y el catálogo de tools. **Sin dependencias**, y es lo que prueba la suite (`apps/api/tests/test_mcp.py`) |
|
|
113
|
+
| `meryn_mcp/servidor.py` | el cableado del SDK de MCP. Lo mínimo que no se puede probar sin el paquete `mcp` |
|
|
114
|
+
|
|
115
|
+
⚠️ **Es un paquete y no dos módulos sueltos**, y el motivo aparece recién al publicar: el
|
|
116
|
+
wheel dejaba un top-level llamado `servidor` en el `site-packages` de cualquiera que lo
|
|
117
|
+
instalara. Adentro del repo daba igual; distribuido, no.
|
|
118
|
+
|
|
119
|
+
Publicar: `uv build && uv publish` desde `clients/mcp/`. ⚠️ Subir la versión en
|
|
120
|
+
`pyproject.toml` antes — PyPI no deja re-subir una versión ya publicada.
|
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""El cliente de la API de meryn y el catálogo de tools del MCP. **Sin dependencias.**
|
|
2
|
+
|
|
3
|
+
Está separado de `servidor.py` —que es el cableado del SDK de MCP— por una razón
|
|
4
|
+
práctica: así esto se puede importar y probar desde la suite de Django, que no tiene (ni
|
|
5
|
+
debe tener) el paquete `mcp` instalado. El servidor web no es un cliente de MCP; meterle
|
|
6
|
+
esa dependencia sería hacerla viajar a producción para nada.
|
|
7
|
+
|
|
8
|
+
Usa sólo `urllib` de la stdlib. No es purismo: el MCP lo instala **el cliente** en su
|
|
9
|
+
máquina, y cada dependencia es una cosa más que puede chocar con lo que ya tenga.
|
|
10
|
+
|
|
11
|
+
## ⚠️ El catálogo de tools se DECLARA; la ayuda de los parámetros se DERIVA
|
|
12
|
+
|
|
13
|
+
Las dos mitades, y las dos a propósito:
|
|
14
|
+
|
|
15
|
+
- **El catálogo es una decisión de producto**: qué diez cosas tiene sentido que un agente
|
|
16
|
+
haga, no una proyección mecánica de los veinte endpoints. Y las descripciones van en
|
|
17
|
+
inglés escritas para un agente, no los docstrings internos. Lo que sí hay es un
|
|
18
|
+
guardián (`test_mcp.py`) que exige que cada `path` de acá exista en el esquema OpenAPI:
|
|
19
|
+
una ruta renombrada rompe un test, no la instalación de un cliente.
|
|
20
|
+
- **Las perillas se derivan en vivo** de `GET /engines/{key}/params/`, que las saca de
|
|
21
|
+
`mmm_param*` — las mismas filas que dibujan los modales, con sus cotas, su default y su
|
|
22
|
+
`help_text` (que `params.E003` ya exige). Escribirlas acá sería una sexta capa de
|
|
23
|
+
defaults, y quedaría vieja el día que alguien edite una fila desde el admin.
|
|
24
|
+
|
|
25
|
+
## ⚠️ Lo que cuesta plata se marca y pide confirmación EXPLÍCITA
|
|
26
|
+
|
|
27
|
+
`run_model` y `run_allocator` lanzan contenedores de Cloud Run: cada uno son decenas de
|
|
28
|
+
minutos de cómputo y un slot de la cuota del plan. Un agente las va a llamar solo, así
|
|
29
|
+
que no alcanza con avisarlo en la descripción — **el server las rechaza sin
|
|
30
|
+
`confirm=true`**. Es una guarda nuestra, no una sugerencia al modelo.
|
|
31
|
+
|
|
32
|
+
Y además van con `Idempotency-Key`: un reintento del agente no tiene que lanzar una
|
|
33
|
+
segunda corrida. La key la genera este cliente a partir de la operación y sus argumentos,
|
|
34
|
+
así que dos llamadas idénticas colapsan en una sin que el agente tenga que acordarse.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
|
|
46
|
+
#: Tope duro de items por respuesta. Un agente que se trae las 2806 filas de un mapeo al
|
|
47
|
+
#: contexto es un modo de falla real: la ventana se llena de datos que no va a usar y se
|
|
48
|
+
#: pierde el hilo de la tarea. La API ya pagina; acá se pide chico por defecto.
|
|
49
|
+
PAGINA = 25
|
|
50
|
+
|
|
51
|
+
TIMEOUT = 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ErrorDeApi(Exception):
|
|
55
|
+
"""Un error que vale la pena mostrarle al agente tal cual."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, status, cuerpo):
|
|
58
|
+
self.status = status
|
|
59
|
+
self.cuerpo = cuerpo
|
|
60
|
+
# ⚠️ **`error` Y `detail`.** Nuestros endpoints usan `error`; los 401/403/429 los
|
|
61
|
+
# arma DRF y usan `detail`. Leyendo sólo el primero, el agente recibía
|
|
62
|
+
# `HTTP 403: None` en vez de *"This API key is read-only"* — o sea el único caso
|
|
63
|
+
# en que el mensaje le dice qué hacer. Se vio corriéndolo contra el server.
|
|
64
|
+
detalle = (cuerpo.get('error') or cuerpo.get('detail')) if isinstance(cuerpo, dict) else cuerpo
|
|
65
|
+
super().__init__(f'HTTP {status}: {detalle}')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Tool:
|
|
70
|
+
nombre: str
|
|
71
|
+
descripcion: str
|
|
72
|
+
metodo: str
|
|
73
|
+
#: Con marcadores `{}` para los argumentos de ruta: `models/{model_id}/metrics/`.
|
|
74
|
+
path: str
|
|
75
|
+
#: Nombre → descripción. Los que aparecen en `path` son de ruta; el resto, query (en
|
|
76
|
+
#: GET) o cuerpo (en POST).
|
|
77
|
+
argumentos: dict = field(default_factory=dict)
|
|
78
|
+
obligatorios: tuple = ()
|
|
79
|
+
#: ¿Lanza cómputo? Si sí, exige `confirm=true`.
|
|
80
|
+
cuesta_computo: bool = False
|
|
81
|
+
#: ¿El cliente puede rellenar `limit` solo cuando el agente lo omite?
|
|
82
|
+
#:
|
|
83
|
+
#: ⚠️ **`False` en las tools cuyo dataset ES un gráfico**, y no es una preferencia: su
|
|
84
|
+
#: descripción dice *"omitilo para traer la serie entera"*, así que rellenarlo hacía
|
|
85
|
+
#: que **seguir la instrucción produjera el recorte**. Un agente pedía el ajuste de un
|
|
86
|
+
#: modelo de 104 semanas, recibía 25 filas y reportaba "predice bien hasta junio". La
|
|
87
|
+
#: respuesta trae `count` y `truncated`, pero el agente no tiene motivo para mirarlos:
|
|
88
|
+
#: pidió la serie completa. `_recorte` del lado de la API está escrito para esto mismo.
|
|
89
|
+
pagina_por_defecto: bool = True
|
|
90
|
+
|
|
91
|
+
def argumentos_de_ruta(self):
|
|
92
|
+
return tuple(p.split('}')[0] for p in self.path.split('{')[1:])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: ⚠️ El argumento que toda tool costosa exige. Se declara una vez para que la guarda y
|
|
96
|
+
#: el esquema que ve el agente no puedan decir cosas distintas.
|
|
97
|
+
CONFIRMACION = 'confirm'
|
|
98
|
+
_AYUDA_CONFIRMACION = (
|
|
99
|
+
'Must be true. Launching costs real compute (tens of minutes of Cloud Run) and uses '
|
|
100
|
+
'one of the team plan concurrent-run slots, so it is never implicit: ask the user '
|
|
101
|
+
'before setting it.'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
TOOLS: tuple[Tool, ...] = (
|
|
105
|
+
Tool(
|
|
106
|
+
nombre='list_engines',
|
|
107
|
+
descripcion=(
|
|
108
|
+
'List the MMM engines available (Robyn, Meridian, PyMC): which ones support '
|
|
109
|
+
'per-market (geo) data and which have a budget allocator. Start here when you '
|
|
110
|
+
'do not know what the account can do.'
|
|
111
|
+
),
|
|
112
|
+
metodo='GET',
|
|
113
|
+
path='engines/',
|
|
114
|
+
),
|
|
115
|
+
Tool(
|
|
116
|
+
nombre='list_engine_params',
|
|
117
|
+
descripcion=(
|
|
118
|
+
'The full configuration surface of one engine: every knob with its type, '
|
|
119
|
+
'default, bounds and help text, plus the effort levels and the prior families '
|
|
120
|
+
'it offers. This is the schema of the `config` object that create_model '
|
|
121
|
+
'accepts — read it before building a config, because it is served live from '
|
|
122
|
+
'the database and changes without a release.'
|
|
123
|
+
),
|
|
124
|
+
metodo='GET',
|
|
125
|
+
path='engines/{engine}/params/',
|
|
126
|
+
argumentos={'engine': 'Engine key, e.g. "robyn", "meridian" or "pymc".'},
|
|
127
|
+
obligatorios=('engine',),
|
|
128
|
+
),
|
|
129
|
+
Tool(
|
|
130
|
+
nombre='list_projects',
|
|
131
|
+
descripcion='List the projects visible to this key, newest configuration first.',
|
|
132
|
+
metodo='GET',
|
|
133
|
+
path='projects/',
|
|
134
|
+
argumentos={'limit': 'Max projects to return.', 'offset': 'Skip this many.'},
|
|
135
|
+
),
|
|
136
|
+
Tool(
|
|
137
|
+
nombre='get_project',
|
|
138
|
+
descripcion=(
|
|
139
|
+
'One project with its node graph: the datasets, mappings, periods, models and '
|
|
140
|
+
'allocators it holds, and which engines its level allows.'
|
|
141
|
+
),
|
|
142
|
+
metodo='GET',
|
|
143
|
+
path='projects/{project_id}/',
|
|
144
|
+
argumentos={'project_id': 'Numeric project id.'},
|
|
145
|
+
obligatorios=('project_id',),
|
|
146
|
+
),
|
|
147
|
+
Tool(
|
|
148
|
+
nombre='list_models',
|
|
149
|
+
descripcion=(
|
|
150
|
+
'List trained or configured models. Filter by `engine`, `status` '
|
|
151
|
+
'("configured", "processing", "completed", "error") or `project`.'
|
|
152
|
+
),
|
|
153
|
+
metodo='GET',
|
|
154
|
+
path='models/',
|
|
155
|
+
argumentos={
|
|
156
|
+
'engine': 'Only this engine.',
|
|
157
|
+
'status': 'Only this status.',
|
|
158
|
+
'project': 'Only models of this project id.',
|
|
159
|
+
'limit': 'Max models to return.',
|
|
160
|
+
'offset': 'Skip this many.',
|
|
161
|
+
},
|
|
162
|
+
),
|
|
163
|
+
Tool(
|
|
164
|
+
nombre='get_model',
|
|
165
|
+
descripcion='One model: its status, the period it trained on, its data frequency and its allocators.',
|
|
166
|
+
metodo='GET',
|
|
167
|
+
path='models/{model_id}/',
|
|
168
|
+
argumentos={'model_id': 'Model UUID.'},
|
|
169
|
+
obligatorios=('model_id',),
|
|
170
|
+
),
|
|
171
|
+
Tool(
|
|
172
|
+
nombre='get_model_metrics',
|
|
173
|
+
descripcion=(
|
|
174
|
+
'Fit and attribution metrics for a model, as raw numbers plus a `units` map '
|
|
175
|
+
'saying how to read each one. IMPORTANT: values flagged "fraction" are 0-1, '
|
|
176
|
+
'not percentages; a `null` means not measured (never zero); and anything '
|
|
177
|
+
'listed in `not_applicable` does not exist for that engine (Robyn has no MCMC, '
|
|
178
|
+
'so it has no r-hat). Do not convert or fill these in.'
|
|
179
|
+
),
|
|
180
|
+
metodo='GET',
|
|
181
|
+
path='models/{model_id}/metrics/',
|
|
182
|
+
argumentos={
|
|
183
|
+
'model_id': 'Model UUID.',
|
|
184
|
+
'split': 'One of "all", "train", "validation", "test".',
|
|
185
|
+
'geo': 'A market name, or "total" for the national row.',
|
|
186
|
+
'sol_id': 'Robyn only: a specific Pareto solution. Defaults to the selected one.',
|
|
187
|
+
},
|
|
188
|
+
obligatorios=('model_id',),
|
|
189
|
+
),
|
|
190
|
+
Tool(
|
|
191
|
+
nombre='get_model_readiness',
|
|
192
|
+
descripcion=(
|
|
193
|
+
'Whether this model is fit to make decisions with: a green/amber/red verdict '
|
|
194
|
+
'and the findings behind it. IMPORTANT: `state` can be "pending" (the verdict '
|
|
195
|
+
'has not been computed yet) or "not_evaluated" (it ran and could not measure '
|
|
196
|
+
'anything). Neither of those means the model is good — do not report a model '
|
|
197
|
+
'as sound without an "evaluated" state.'
|
|
198
|
+
),
|
|
199
|
+
metodo='GET',
|
|
200
|
+
path='models/{model_id}/readiness/',
|
|
201
|
+
argumentos={'model_id': 'Model UUID.'},
|
|
202
|
+
obligatorios=('model_id',),
|
|
203
|
+
),
|
|
204
|
+
Tool(
|
|
205
|
+
nombre='get_model_logs',
|
|
206
|
+
descripcion=(
|
|
207
|
+
'Current stage, elapsed time and the last worker log lines of a run. Use this '
|
|
208
|
+
'to follow a run you launched, or to find out why one failed. Pass `since` '
|
|
209
|
+
'(ISO timestamp) to get only what is new.'
|
|
210
|
+
),
|
|
211
|
+
metodo='GET',
|
|
212
|
+
path='models/{model_id}/logs/',
|
|
213
|
+
argumentos={'model_id': 'Model UUID.', 'since': 'ISO timestamp; only newer lines.'},
|
|
214
|
+
obligatorios=('model_id',),
|
|
215
|
+
),
|
|
216
|
+
Tool(
|
|
217
|
+
nombre='list_model_runs',
|
|
218
|
+
descripcion=(
|
|
219
|
+
'The last runs of a model: when each started and ended, and how it finished. '
|
|
220
|
+
'Use it to tell "this never ran" from "this failed three times" — and to see '
|
|
221
|
+
'how long a run of this size takes before launching another.'
|
|
222
|
+
),
|
|
223
|
+
metodo='GET',
|
|
224
|
+
path='models/{model_id}/runs/',
|
|
225
|
+
argumentos={'model_id': 'Model UUID.'},
|
|
226
|
+
obligatorios=('model_id',),
|
|
227
|
+
),
|
|
228
|
+
Tool(
|
|
229
|
+
nombre='list_model_charts',
|
|
230
|
+
descripcion=(
|
|
231
|
+
'Which chart datasets a model has, and WHICH AXIS they cut on — read this '
|
|
232
|
+
'before asking for one, instead of guessing the parameter. `axis` is "geo" '
|
|
233
|
+
'(pass a market name), "sol_id" (Robyn, which has hundreds of Pareto '
|
|
234
|
+
'solutions but no markets) or null (nothing to choose). `national_only` lists '
|
|
235
|
+
'the charts that cannot be cut by market at all, with the reason.'
|
|
236
|
+
),
|
|
237
|
+
metodo='GET',
|
|
238
|
+
path='models/{model_id}/charts/',
|
|
239
|
+
argumentos={
|
|
240
|
+
'model_id': 'Model UUID.',
|
|
241
|
+
'geo': 'Market name. Only for engines whose axis is "geo"; omit for national.',
|
|
242
|
+
'sol_id': 'Robyn only: a Pareto solution. Defaults to the one selected in the app.',
|
|
243
|
+
},
|
|
244
|
+
obligatorios=('model_id',),
|
|
245
|
+
),
|
|
246
|
+
Tool(
|
|
247
|
+
nombre='get_model_chart',
|
|
248
|
+
descripcion=(
|
|
249
|
+
'One chart dataset as rows, with a `units` map and `null_means` next to them: '
|
|
250
|
+
'the contribution waterfall, the fit over time, response curves, adstock '
|
|
251
|
+
'decay, convergence, credible intervals, the decomposition. Call '
|
|
252
|
+
'list_model_charts first to see what this model has. IMPORTANT: if '
|
|
253
|
+
'`national_only` is set, the rows are NATIONAL and not the market you asked '
|
|
254
|
+
'for — say so rather than reporting them as that market. Values flagged '
|
|
255
|
+
'"fraction" are 0-1, and a `null` means not measured, never zero.'
|
|
256
|
+
),
|
|
257
|
+
metodo='GET',
|
|
258
|
+
path='models/{model_id}/charts/{dataset}/',
|
|
259
|
+
argumentos={
|
|
260
|
+
'model_id': 'Model UUID.',
|
|
261
|
+
'dataset': 'Dataset name, from list_model_charts.',
|
|
262
|
+
'geo': 'Market name, for engines whose axis is "geo".',
|
|
263
|
+
'sol_id': 'Robyn only: a Pareto solution.',
|
|
264
|
+
'limit': 'Max rows. Omit to get the whole series — a truncated chart looks complete.',
|
|
265
|
+
'offset': 'Skip this many rows.',
|
|
266
|
+
},
|
|
267
|
+
obligatorios=('model_id', 'dataset'),
|
|
268
|
+
# La descripción promete la serie entera; el cliente no la puede recortar sola.
|
|
269
|
+
pagina_por_defecto=False,
|
|
270
|
+
),
|
|
271
|
+
Tool(
|
|
272
|
+
nombre='list_model_results',
|
|
273
|
+
descripcion=(
|
|
274
|
+
'Which finer-grained result tables a model has. Use these when a chart is too '
|
|
275
|
+
'aggregated for the question: the waterfall sums over every date, so '
|
|
276
|
+
'"contribution of TV by week" is not in it, and the decomposition folds '
|
|
277
|
+
'intercept, trend and seasonality into one baseline series.'
|
|
278
|
+
),
|
|
279
|
+
metodo='GET',
|
|
280
|
+
path='models/{model_id}/results/',
|
|
281
|
+
argumentos={'model_id': 'Model UUID.'},
|
|
282
|
+
obligatorios=('model_id',),
|
|
283
|
+
),
|
|
284
|
+
Tool(
|
|
285
|
+
nombre='get_model_results',
|
|
286
|
+
descripcion=(
|
|
287
|
+
'One result table whole, with the market, the date and the Pareto solution as '
|
|
288
|
+
'COLUMNS rather than parameters — one call gets the cube and you group it '
|
|
289
|
+
'yourself. Paginated, so check `count` against what you received before '
|
|
290
|
+
'concluding anything about totals.'
|
|
291
|
+
),
|
|
292
|
+
metodo='GET',
|
|
293
|
+
path='models/{model_id}/results/{family}/',
|
|
294
|
+
argumentos={
|
|
295
|
+
'model_id': 'Model UUID.',
|
|
296
|
+
'family': 'Family name, from list_model_results.',
|
|
297
|
+
'limit': 'Max rows per page.',
|
|
298
|
+
'offset': 'Skip this many.',
|
|
299
|
+
},
|
|
300
|
+
obligatorios=('model_id', 'family'),
|
|
301
|
+
),
|
|
302
|
+
Tool(
|
|
303
|
+
nombre='list_eda',
|
|
304
|
+
descripcion=(
|
|
305
|
+
'The exploratory data analyses (EDA) in this account. EDA is not one of the '
|
|
306
|
+
'MMM engines — it checks a dataset BEFORE training — so it lives on its own '
|
|
307
|
+
'path and does not appear in list_models.'
|
|
308
|
+
),
|
|
309
|
+
metodo='GET',
|
|
310
|
+
path='eda/',
|
|
311
|
+
argumentos={
|
|
312
|
+
'status': 'Filter by status, e.g. "completed".',
|
|
313
|
+
'limit': 'Max to return.',
|
|
314
|
+
'offset': 'Skip this many.',
|
|
315
|
+
},
|
|
316
|
+
),
|
|
317
|
+
Tool(
|
|
318
|
+
nombre='list_eda_charts',
|
|
319
|
+
descripcion='Which EDA datasets a run has (series, correlations, VIF, cost share, lagged correlations).',
|
|
320
|
+
metodo='GET',
|
|
321
|
+
path='eda/{eda_id}/charts/',
|
|
322
|
+
argumentos={'eda_id': 'EDA UUID.', 'geo': 'Market name; omit for the aggregate.'},
|
|
323
|
+
obligatorios=('eda_id',),
|
|
324
|
+
),
|
|
325
|
+
Tool(
|
|
326
|
+
nombre='get_eda_chart',
|
|
327
|
+
descripcion=(
|
|
328
|
+
'One EDA dataset as rows: the variables over time, the correlation pairs, the '
|
|
329
|
+
'VIF per variable with its reading, the cost share, or the lagged '
|
|
330
|
+
'correlations. Use this to judge whether a dataset is fit to train on — '
|
|
331
|
+
'collinearity and flat channels are what make a model unusable later. '
|
|
332
|
+
'IMPORTANT: omitting `geo` gives the AGGREGATE, which is computed on the panel '
|
|
333
|
+
'collapsed by date, not the sum of the markets.'
|
|
334
|
+
),
|
|
335
|
+
metodo='GET',
|
|
336
|
+
path='eda/{eda_id}/charts/{dataset}/',
|
|
337
|
+
argumentos={
|
|
338
|
+
'eda_id': 'EDA UUID.',
|
|
339
|
+
'dataset': 'Dataset name, from list_eda_charts.',
|
|
340
|
+
'geo': 'Market name; omit for the aggregate.',
|
|
341
|
+
'limit': 'Max rows. Omit for the whole series.',
|
|
342
|
+
'offset': 'Skip this many.',
|
|
343
|
+
},
|
|
344
|
+
obligatorios=('eda_id', 'dataset'),
|
|
345
|
+
# La descripción promete la serie entera; el cliente no la puede recortar sola.
|
|
346
|
+
pagina_por_defecto=False,
|
|
347
|
+
),
|
|
348
|
+
Tool(
|
|
349
|
+
nombre='get_allocator',
|
|
350
|
+
descripcion='A budget allocation: its scenario, budget and the national lift it found.',
|
|
351
|
+
metodo='GET',
|
|
352
|
+
path='allocators/{allocator_id}/',
|
|
353
|
+
argumentos={'allocator_id': 'Allocator UUID.'},
|
|
354
|
+
obligatorios=('allocator_id',),
|
|
355
|
+
),
|
|
356
|
+
Tool(
|
|
357
|
+
nombre='get_allocator_channels',
|
|
358
|
+
descripcion=(
|
|
359
|
+
'The per-channel budget split of one market cut: current vs optimal spend and '
|
|
360
|
+
'response. Pass `geo` for a market; the default is the national row. The '
|
|
361
|
+
'`totals` object leaves a response at null when any channel is missing it — '
|
|
362
|
+
'that is not zero, and summing around it would present a partial as a total.'
|
|
363
|
+
),
|
|
364
|
+
metodo='GET',
|
|
365
|
+
path='allocators/{allocator_id}/channels/',
|
|
366
|
+
argumentos={'allocator_id': 'Allocator UUID.', 'geo': 'Market name, or "total".'},
|
|
367
|
+
obligatorios=('allocator_id',),
|
|
368
|
+
),
|
|
369
|
+
Tool(
|
|
370
|
+
nombre='get_allocator_efficiency',
|
|
371
|
+
descripcion=(
|
|
372
|
+
'Current vs optimized per metric, with the improvement — every market cut at '
|
|
373
|
+
'once, with `geo` as a column. IMPORTANT: read `total_means` before adding '
|
|
374
|
+
'anything up. In PyMC the `total` rows ARE the sum of the markets (it '
|
|
375
|
+
'optimises jointly); in Meridian each market is an independent optimisation '
|
|
376
|
+
'and its rows do NOT add up to the total.'
|
|
377
|
+
),
|
|
378
|
+
metodo='GET',
|
|
379
|
+
path='allocators/{allocator_id}/efficiency/',
|
|
380
|
+
argumentos={
|
|
381
|
+
'allocator_id': 'Allocator UUID.',
|
|
382
|
+
'limit': 'Max rows per page.',
|
|
383
|
+
'offset': 'Skip this many.',
|
|
384
|
+
},
|
|
385
|
+
obligatorios=('allocator_id',),
|
|
386
|
+
),
|
|
387
|
+
# ── Los tres pasos del flujo, que faltaban ────────────────────────────────
|
|
388
|
+
#
|
|
389
|
+
# ⚠️ Sin éstos, `create_model` pide un `period_id` que por MCP sólo se podía sacar
|
|
390
|
+
# leyendo el `object_id` de los nodos que devuelve `get_project` — o sea que el camino
|
|
391
|
+
# de escritura empezaba con una adivinanza.
|
|
392
|
+
Tool(
|
|
393
|
+
nombre='list_datasets',
|
|
394
|
+
descripcion='The uploaded CSVs of a project, with their row and column counts.',
|
|
395
|
+
metodo='GET',
|
|
396
|
+
path='projects/{project_id}/datasets/',
|
|
397
|
+
argumentos={
|
|
398
|
+
'project_id': 'Numeric project id.',
|
|
399
|
+
'limit': 'Max to return.',
|
|
400
|
+
'offset': 'Skip this many.',
|
|
401
|
+
},
|
|
402
|
+
obligatorios=('project_id',),
|
|
403
|
+
),
|
|
404
|
+
Tool(
|
|
405
|
+
nombre='list_mappings',
|
|
406
|
+
descripcion=(
|
|
407
|
+
'The variable mappings of a dataset: which role each column plays, the date '
|
|
408
|
+
'range of the file and the frequency measured from it. Read this to know what '
|
|
409
|
+
'a model will actually train on.'
|
|
410
|
+
),
|
|
411
|
+
metodo='GET',
|
|
412
|
+
path='datasets/{dataset_id}/mappings/',
|
|
413
|
+
argumentos={
|
|
414
|
+
'dataset_id': 'Numeric dataset id.',
|
|
415
|
+
'limit': 'Max to return.',
|
|
416
|
+
'offset': 'Skip this many.',
|
|
417
|
+
},
|
|
418
|
+
obligatorios=('dataset_id',),
|
|
419
|
+
),
|
|
420
|
+
Tool(
|
|
421
|
+
nombre='list_periods',
|
|
422
|
+
descripcion=(
|
|
423
|
+
'The periods (date windows) defined on a mapping. A period id is what '
|
|
424
|
+
'create_model requires — the window decides which rows train, and it is asked '
|
|
425
|
+
'for explicitly rather than guessed.'
|
|
426
|
+
),
|
|
427
|
+
metodo='GET',
|
|
428
|
+
path='mappings/{mapping_id}/periods/',
|
|
429
|
+
argumentos={
|
|
430
|
+
'mapping_id': 'Numeric mapping id.',
|
|
431
|
+
'limit': 'Max to return.',
|
|
432
|
+
'offset': 'Skip this many.',
|
|
433
|
+
},
|
|
434
|
+
obligatorios=('mapping_id',),
|
|
435
|
+
),
|
|
436
|
+
Tool(
|
|
437
|
+
nombre='create_model',
|
|
438
|
+
descripcion=(
|
|
439
|
+
'Create a model on an existing period. It does NOT start training: the model '
|
|
440
|
+
'is left ready to run, so you can build and review a configuration safely. '
|
|
441
|
+
'Build `config` from list_engine_params; anything you omit takes the default '
|
|
442
|
+
'from the database. Upload and map a dataset in the web app first — this API '
|
|
443
|
+
'does not do that step.'
|
|
444
|
+
),
|
|
445
|
+
metodo='POST',
|
|
446
|
+
path='models/create/',
|
|
447
|
+
argumentos={
|
|
448
|
+
'engine': 'Engine key.',
|
|
449
|
+
'period_id': 'Numeric id of the period (Experiment) to train on.',
|
|
450
|
+
'name': 'Optional name. A default naming the engine is used otherwise.',
|
|
451
|
+
'config': 'Object of knob → value, as described by list_engine_params.',
|
|
452
|
+
},
|
|
453
|
+
obligatorios=('engine', 'period_id'),
|
|
454
|
+
),
|
|
455
|
+
Tool(
|
|
456
|
+
nombre='run_model',
|
|
457
|
+
descripcion=(
|
|
458
|
+
'Start training a configured model. COSTS COMPUTE: tens of minutes of Cloud '
|
|
459
|
+
'Run and one concurrent-run slot of the plan. Returns immediately; follow it '
|
|
460
|
+
'with get_model_logs. A model already running returns 409 — do not retry it.'
|
|
461
|
+
),
|
|
462
|
+
metodo='POST',
|
|
463
|
+
path='models/{model_id}/run/',
|
|
464
|
+
argumentos={'model_id': 'Model UUID.', CONFIRMACION: _AYUDA_CONFIRMACION},
|
|
465
|
+
obligatorios=('model_id', CONFIRMACION),
|
|
466
|
+
cuesta_computo=True,
|
|
467
|
+
),
|
|
468
|
+
Tool(
|
|
469
|
+
nombre='cancel_model_run',
|
|
470
|
+
descripcion=(
|
|
471
|
+
'Stop a queued or running model and leave it ready to run again. Cancelling is '
|
|
472
|
+
'a request, not a kill: the container can take a few seconds to stop, and it '
|
|
473
|
+
'saves nothing from the cancelled run.'
|
|
474
|
+
),
|
|
475
|
+
metodo='POST',
|
|
476
|
+
path='models/{model_id}/cancel/',
|
|
477
|
+
argumentos={'model_id': 'Model UUID.'},
|
|
478
|
+
obligatorios=('model_id',),
|
|
479
|
+
),
|
|
480
|
+
Tool(
|
|
481
|
+
nombre='create_allocator',
|
|
482
|
+
descripcion=(
|
|
483
|
+
'Create a budget allocation on a trained model. Does NOT run it. Which '
|
|
484
|
+
'scenarios exist, and which of them need a target instead of a budget, comes '
|
|
485
|
+
'from list_engine_params for `<engine>_allocator`.'
|
|
486
|
+
),
|
|
487
|
+
metodo='POST',
|
|
488
|
+
path='allocators/create/',
|
|
489
|
+
argumentos={
|
|
490
|
+
'model_id': 'UUID of the trained model to optimise.',
|
|
491
|
+
'name': 'Name for this allocation.',
|
|
492
|
+
'start_date': 'ISO date of the optimisation window start.',
|
|
493
|
+
'end_date': 'ISO date of the window end.',
|
|
494
|
+
'optimization_scenario': 'Scenario key, e.g. "max_response" or "fixed_budget".',
|
|
495
|
+
'total_budget': 'Budget to allocate, for budget scenarios.',
|
|
496
|
+
'target_value': 'Target, for target scenarios.',
|
|
497
|
+
'optimize_by_geo': (
|
|
498
|
+
'Per-market optimisation. Meridian and PyMC only, and it means a different '
|
|
499
|
+
'thing in each: PyMC moves budget between markets, Meridian runs one '
|
|
500
|
+
'optimisation inside each.'
|
|
501
|
+
),
|
|
502
|
+
'channel_constraints': 'Optional per-channel bounds envelope.',
|
|
503
|
+
},
|
|
504
|
+
obligatorios=('model_id', 'name', 'start_date', 'end_date'),
|
|
505
|
+
),
|
|
506
|
+
Tool(
|
|
507
|
+
nombre='run_allocator',
|
|
508
|
+
descripcion=(
|
|
509
|
+
'Run a configured budget allocation. COSTS COMPUTE, though much less than training. Returns immediately.'
|
|
510
|
+
),
|
|
511
|
+
metodo='POST',
|
|
512
|
+
path='allocators/{allocator_id}/run/',
|
|
513
|
+
argumentos={'allocator_id': 'Allocator UUID.', CONFIRMACION: _AYUDA_CONFIRMACION},
|
|
514
|
+
obligatorios=('allocator_id', CONFIRMACION),
|
|
515
|
+
cuesta_computo=True,
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
POR_NOMBRE = {t.nombre: t for t in TOOLS}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
#: Los argumentos que NO son texto. Todo lo demás es `str`: los ids son UUID o enteros
|
|
523
|
+
#: que viajan en la URL, y las fechas van en ISO.
|
|
524
|
+
#:
|
|
525
|
+
#: ⚠️ `config` tiene que ser **objeto** y no string. Declarado como string, un agente
|
|
526
|
+
#: mandaría el JSON serializado y la API lo rechazaría — con un error sobre el tipo del
|
|
527
|
+
#: campo, que no dice nada de cuál era el problema real.
|
|
528
|
+
_TIPOS = {CONFIRMACION: bool, 'config': dict, 'channel_constraints': dict}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
#: El nombre con el que este paquete se instala. ⚠️ Es la pata frágil de `version()`: un
|
|
532
|
+
#: typo acá no falla, **cae al fallback para siempre** y el server reporta `0+local` en
|
|
533
|
+
#: producción sin que nadie se entere. Por eso es una constante y hay un test que la
|
|
534
|
+
#: compara contra el `name` del `pyproject.toml`.
|
|
535
|
+
PAQUETE = 'meryn-mcp'
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def version() -> str:
|
|
539
|
+
"""La versión de este paquete, DERIVADA de su metadata.
|
|
540
|
+
|
|
541
|
+
⚠️ Sin esto `MCPServer` arranca con `version=''` y el cliente, al conectar, muestra el
|
|
542
|
+
server sin versión — que es lo primero que se mira cuando algo no anda y hay que saber
|
|
543
|
+
qué está corriendo. Derivada y no escrita acá: un número a mano se desincroniza de
|
|
544
|
+
`pyproject.toml` en el primer release.
|
|
545
|
+
|
|
546
|
+
El fallback cubre correr el archivo sin instalar el paquete, donde no hay metadata que
|
|
547
|
+
leer. Dice `+local` a propósito: *"esto no es un release"* es información, y un `0.0.0`
|
|
548
|
+
inventado se leería como una versión de verdad.
|
|
549
|
+
"""
|
|
550
|
+
from importlib.metadata import PackageNotFoundError
|
|
551
|
+
from importlib.metadata import version as _del_paquete
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
return _del_paquete(PAQUETE)
|
|
555
|
+
except PackageNotFoundError:
|
|
556
|
+
return '0+local'
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def tipo_de(argumento: str) -> type:
|
|
560
|
+
"""El tipo de un argumento de tool.
|
|
561
|
+
|
|
562
|
+
⚠️ **Es lo único de la forma del esquema que vive acá.** El JSON Schema lo genera el
|
|
563
|
+
SDK de MCP a partir de la firma de la función (`servidor._fabricar`): hubo una versión
|
|
564
|
+
que además lo armaba a mano en este módulo, o sea **dos generadores del mismo
|
|
565
|
+
documento** — y el que el agente ve es el del SDK, así que el otro podía estar
|
|
566
|
+
equivocado sin que nada fallara. Quedó sólo la decisión que es nuestra, y probada.
|
|
567
|
+
"""
|
|
568
|
+
return _TIPOS.get(argumento, str)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class Cliente:
|
|
572
|
+
"""HTTP contra `/api/v1/`. Una instancia por proceso."""
|
|
573
|
+
|
|
574
|
+
def __init__(self, base_url: str, api_key: str, timeout: int = TIMEOUT, abrir=None):
|
|
575
|
+
self.base = base_url.rstrip('/') + '/api/v1/'
|
|
576
|
+
self.api_key = api_key
|
|
577
|
+
self.timeout = timeout
|
|
578
|
+
# Inyectable para poder probar sin red. No es un `if testing`: es el único
|
|
579
|
+
# parámetro que el test cambia, y así el camino que se prueba es el real.
|
|
580
|
+
self._abrir = abrir or urllib.request.urlopen
|
|
581
|
+
|
|
582
|
+
def llamar(self, nombre: str, argumentos: dict) -> dict:
|
|
583
|
+
tool = POR_NOMBRE.get(nombre)
|
|
584
|
+
if tool is None:
|
|
585
|
+
raise ErrorDeApi(400, {'error': f'unknown tool {nombre!r}'})
|
|
586
|
+
|
|
587
|
+
argumentos = dict(argumentos or {})
|
|
588
|
+
|
|
589
|
+
# ⚠️ **La guarda de cómputo va ANTES del chequeo de obligatorios**, y el orden es
|
|
590
|
+
# el punto: `confirm` también es obligatorio, así que al revés la respuesta era
|
|
591
|
+
# *"missing required argument(s): ['confirm']"* y el mensaje que le dice al agente
|
|
592
|
+
# **que le pregunte al usuario** no se mostraba nunca. Se vio corriéndolo.
|
|
593
|
+
if tool.cuesta_computo and argumentos.get(CONFIRMACION) is not True:
|
|
594
|
+
# La guarda es NUESTRA, no una sugerencia en la descripción: el agente llama
|
|
595
|
+
# solo y acá cada llamada son decenas de minutos de cómputo facturado.
|
|
596
|
+
raise ErrorDeApi(
|
|
597
|
+
400,
|
|
598
|
+
{
|
|
599
|
+
'error': (
|
|
600
|
+
f'{nombre} launches real compute, so it needs confirm=true. Ask the user before launching.'
|
|
601
|
+
)
|
|
602
|
+
},
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Va DESPUÉS de la guarda de cómputo (ver arriba) y ANTES de armar la ruta: los
|
|
606
|
+
# argumentos de ruta se saca con `pop`, así que uno ausente sería un `KeyError`.
|
|
607
|
+
faltan = [a for a in tool.obligatorios if argumentos.get(a) in (None, '')]
|
|
608
|
+
if faltan:
|
|
609
|
+
raise ErrorDeApi(400, {'error': f'missing required argument(s): {faltan}'})
|
|
610
|
+
|
|
611
|
+
path = tool.path
|
|
612
|
+
for arg in tool.argumentos_de_ruta():
|
|
613
|
+
path = path.replace('{' + arg + '}', urllib.parse.quote(str(argumentos.pop(arg))))
|
|
614
|
+
argumentos.pop(CONFIRMACION, None)
|
|
615
|
+
|
|
616
|
+
if tool.metodo == 'GET':
|
|
617
|
+
# Pedir chico por defecto SÓLO donde la tool declara paginar —un `limit` en
|
|
618
|
+
# un endpoint que no lo acepta sería un parámetro inventado— y sólo donde la
|
|
619
|
+
# tool lo PERMITE: ver `Tool.pagina_por_defecto`.
|
|
620
|
+
if 'limit' in tool.argumentos and tool.pagina_por_defecto:
|
|
621
|
+
argumentos.setdefault('limit', PAGINA)
|
|
622
|
+
query = {k: v for k, v in argumentos.items() if v not in (None, '')}
|
|
623
|
+
url = self.base + path + ('?' + urllib.parse.urlencode(query) if query else '')
|
|
624
|
+
return self._pedir('GET', url, None, None)
|
|
625
|
+
|
|
626
|
+
cuerpo = {k: v for k, v in argumentos.items() if v is not None}
|
|
627
|
+
return self._pedir('POST', self.base + path, cuerpo, clave_idempotente(nombre, path, cuerpo))
|
|
628
|
+
|
|
629
|
+
def _pedir(self, metodo, url, cuerpo, idempotency_key):
|
|
630
|
+
datos = json.dumps(cuerpo).encode() if cuerpo is not None else None
|
|
631
|
+
pedido = urllib.request.Request(url, data=datos, method=metodo)
|
|
632
|
+
pedido.add_header('Authorization', f'Bearer {self.api_key}')
|
|
633
|
+
pedido.add_header('Accept', 'application/json')
|
|
634
|
+
if datos is not None:
|
|
635
|
+
pedido.add_header('Content-Type', 'application/json')
|
|
636
|
+
if idempotency_key:
|
|
637
|
+
pedido.add_header('Idempotency-Key', idempotency_key)
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
with self._abrir(pedido, timeout=self.timeout) as respuesta:
|
|
641
|
+
return json.loads(respuesta.read() or b'{}')
|
|
642
|
+
except urllib.error.HTTPError as e:
|
|
643
|
+
crudo = e.read()
|
|
644
|
+
try:
|
|
645
|
+
cuerpo_error = json.loads(crudo or b'{}')
|
|
646
|
+
except ValueError:
|
|
647
|
+
cuerpo_error = {'error': (crudo or b'').decode(errors='replace')[:500]}
|
|
648
|
+
raise ErrorDeApi(e.code, cuerpo_error) from None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def clave_idempotente(nombre, path, cuerpo) -> str:
|
|
652
|
+
"""Una key estable para `(tool, ruta, cuerpo)`.
|
|
653
|
+
|
|
654
|
+
⚠️ **La genera el cliente y no el agente**, y es lo que hace que la protección exista
|
|
655
|
+
de verdad: un agente que reintenta porque no vio la respuesta manda exactamente los
|
|
656
|
+
mismos argumentos, así que la key coincide y la API devuelve lo que ya creó en vez de
|
|
657
|
+
lanzar una segunda corrida. Dejársela al agente sería confiar en que se acuerde.
|
|
658
|
+
|
|
659
|
+
Lleva el cuerpo ordenado: el mismo pedido serializado en otro orden tiene que dar la
|
|
660
|
+
misma key, o un reintento legítimo se leería como una operación nueva.
|
|
661
|
+
"""
|
|
662
|
+
huella = json.dumps([nombre, path, cuerpo], sort_keys=True, default=str)
|
|
663
|
+
return 'mcp-' + hashlib.sha256(huella.encode()).hexdigest()[:32]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""El servidor MCP: cableado del SDK, y nada más.
|
|
2
|
+
|
|
3
|
+
Todo lo que se puede probar vive en `meryn_mcp.py`, que no tiene dependencias. Acá queda
|
|
4
|
+
sólo lo que no se puede probar sin el paquete `mcp` instalado. Si este archivo crece,
|
|
5
|
+
algo se está escribiendo en el lugar equivocado.
|
|
6
|
+
|
|
7
|
+
## ⚠️ El esquema de cada tool lo genera el SDK, no nosotros
|
|
8
|
+
|
|
9
|
+
`MCPServer.add_tool` deriva el JSON Schema de la **firma** de la función, así que las
|
|
10
|
+
funciones se fabrican desde el catálogo (`meryn_mcp.TOOLS`) con su firma y sus
|
|
11
|
+
`Annotated[..., Field(description=...)]` puestos. Hubo una versión que además armaba el
|
|
12
|
+
esquema a mano en `meryn_mcp.py`: dos generadores del mismo documento, y el que el agente
|
|
13
|
+
ve es el del SDK — el otro se habría podido equivocar sin que nada fallara. Lo que quedó
|
|
14
|
+
en el módulo puro es la única decisión que es nuestra y no del SDK: **qué tipo tiene cada
|
|
15
|
+
argumento** (`meryn_mcp.tipo_de`), que sí está probada.
|
|
16
|
+
|
|
17
|
+
## ⚠️ Verificado contra el SDK instalado, no de memoria
|
|
18
|
+
|
|
19
|
+
La primera versión de este archivo usaba la API v1 (`@server.list_tools()`,
|
|
20
|
+
`inputSchema`) y **no arrancaba**: el SDK está en 2.x, donde `FastMCP` pasó a ser
|
|
21
|
+
`MCPServer`, las tools se registran por función y el campo es `input_schema`. Se descubrió
|
|
22
|
+
corriéndolo, que es la única forma. Para correrlo a mano:
|
|
23
|
+
|
|
24
|
+
MERYN_API_KEY=mk_… MERYN_BASE_URL=https://meryn.ai python servidor.py
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import inspect
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from typing import Annotated
|
|
35
|
+
|
|
36
|
+
from mcp.server.mcpserver import MCPServer
|
|
37
|
+
from meryn_mcp import TOOLS, Cliente, ErrorDeApi, tipo_de, version
|
|
38
|
+
from pydantic import Field
|
|
39
|
+
|
|
40
|
+
BASE_POR_DEFECTO = 'https://meryn.ai'
|
|
41
|
+
|
|
42
|
+
servidor = MCPServer('meryn', version=version())
|
|
43
|
+
_cliente_cache: Cliente | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cliente() -> Cliente:
|
|
47
|
+
"""El cliente HTTP, uno por proceso.
|
|
48
|
+
|
|
49
|
+
La key se lee en el primer uso y se falla con el motivo: sin esto el primer síntoma
|
|
50
|
+
sería un 401 en cada tool, que se lee como "la key no sirve" en vez de "no hay key".
|
|
51
|
+
"""
|
|
52
|
+
global _cliente_cache
|
|
53
|
+
if _cliente_cache is None:
|
|
54
|
+
key = os.environ.get('MERYN_API_KEY')
|
|
55
|
+
if not key:
|
|
56
|
+
sys.exit('MERYN_API_KEY no está seteada. Es la key que meryn emitió para tu team.')
|
|
57
|
+
_cliente_cache = Cliente(os.environ.get('MERYN_BASE_URL', BASE_POR_DEFECTO), key)
|
|
58
|
+
return _cliente_cache
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _anotacion(nombre, ayuda):
|
|
62
|
+
return Annotated[tipo_de(nombre), Field(description=ayuda)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fabricar(tool):
|
|
66
|
+
"""Una función con la firma que el SDK necesita para generar el esquema."""
|
|
67
|
+
|
|
68
|
+
async def llamar(**argumentos):
|
|
69
|
+
try:
|
|
70
|
+
# El cliente es síncrono (urllib) y acá estamos en un loop: va a un hilo para
|
|
71
|
+
# no bloquear el servidor mientras una corrida tarda.
|
|
72
|
+
return await asyncio.to_thread(cliente().llamar, tool.nombre, argumentos)
|
|
73
|
+
except ErrorDeApi as e:
|
|
74
|
+
# El error de la API se devuelve COMO RESULTADO de la tool, no como excepción
|
|
75
|
+
# del protocolo: un 409 "ya está corriendo" o un 402 "tu plan no alcanza" son
|
|
76
|
+
# cosas que el agente tiene que poder leer y decidir qué hacer, no fallas del
|
|
77
|
+
# transporte.
|
|
78
|
+
return json.loads(json.dumps({'error': str(e), 'detail': e.cuerpo}, default=str))
|
|
79
|
+
|
|
80
|
+
llamar.__signature__ = inspect.Signature(
|
|
81
|
+
[
|
|
82
|
+
inspect.Parameter(
|
|
83
|
+
nombre,
|
|
84
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
85
|
+
annotation=_anotacion(nombre, ayuda),
|
|
86
|
+
# Los obligatorios van SIN default: así el esquema los marca `required`.
|
|
87
|
+
default=inspect.Parameter.empty if nombre in tool.obligatorios else None,
|
|
88
|
+
)
|
|
89
|
+
for nombre, ayuda in tool.argumentos.items()
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
llamar.__annotations__ = {n: _anotacion(n, a) for n, a in tool.argumentos.items()}
|
|
93
|
+
llamar.__name__ = tool.nombre
|
|
94
|
+
return llamar
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
for _tool in TOOLS:
|
|
98
|
+
servidor.add_tool(_fabricar(_tool), name=_tool.nombre, description=_tool.descripcion)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main():
|
|
102
|
+
"""El entry point que declara `pyproject.toml` (`meryn-mcp`)."""
|
|
103
|
+
servidor.run('stdio')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == '__main__':
|
|
107
|
+
main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "meryn-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for the meryn API — read your MMM results and launch runs from Claude"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "meryn", email = "hi@meryn.ai" }]
|
|
9
|
+
keywords = ["mcp", "mmm", "marketing-mix-modeling", "robyn", "meridian", "pymc-marketing"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
15
|
+
]
|
|
16
|
+
# Una sola dependencia, y es el SDK del protocolo. Todo lo demás (HTTP, JSON, hashing)
|
|
17
|
+
# sale de la stdlib: el que instala esto es un cliente en su máquina, y cada dependencia
|
|
18
|
+
# de más es algo que puede chocar con lo que ya tenga.
|
|
19
|
+
# ⚠️ Pineado al MAJOR: entre 1.x y 2.x el SDK renombró `FastMCP` a `MCPServer`, cambió
|
|
20
|
+
# cómo se registran las tools y renombró `inputSchema` a `input_schema`. El código de acá
|
|
21
|
+
# es 2.x, y con la 1.x el servidor no arranca.
|
|
22
|
+
dependencies = ["mcp>=2.0,<3"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://meryn.ai"
|
|
26
|
+
Documentation = "https://meryn.ai/docs/api/mcp/"
|
|
27
|
+
"API reference" = "https://meryn.ai/api/v1/docs/"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
meryn-mcp = "meryn_mcp.servidor:main"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
# ⚠️ **Un PAQUETE, no dos módulos sueltos**, y el motivo aparece recién al publicar: con
|
|
37
|
+
# `include = ["meryn_mcp.py", "servidor.py"]` el wheel instalaba un módulo top-level
|
|
38
|
+
# llamado **`servidor`** en el `site-packages` de cualquiera — un nombre genérico que
|
|
39
|
+
# puede chocar con lo que el usuario ya tenga. Adentro del repo daba igual; distribuido,
|
|
40
|
+
# no. Ahora todo cuelga de `meryn_mcp/`, que es el único nombre que este paquete toma.
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["meryn_mcp"]
|