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.
@@ -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
@@ -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"]