Metadata-Version: 2.4
Name: mcp-server-meridian
Version: 0.1.1
Summary: MCP server exposing Google Meridian Marketing Mix Model trace data as structured tools
Author: BlueAlpha
License: MIT
Requires-Python: >=3.10
Requires-Dist: arviz<1.0
Requires-Dist: google-meridian
Requires-Dist: mcp[cli]>=1.1.3
Requires-Dist: numpy
Requires-Dist: xarray
Description-Content-Type: text/markdown

# mcp-server-meridian

A Model Context Protocol (MCP) server that exposes Google Meridian Marketing Mix Model (MMM) trace data as structured, queryable tools. Point it at any pickled Meridian model and get instant access to ROI analysis, saturation diagnostics, budget simulation, and more — all through Claude or any MCP-compatible client.

## Why

Marketing Mix Models produce rich posterior distributions, but the outputs are locked inside ArviZ traces that require statistical expertise to interpret. This server bridges the gap: it loads a fitted Meridian model and exposes 8 tools that compute MMM insights on demand, making Bayesian marketing analytics conversational.

## Use Cases & Example Prompts

### For Marketing Managers & Media Buyers

**Weekly performance check-in**
> "Show me channel contributions for the last 4 weeks vs the prior 4 weeks"

Uses `get_weekly_contributions(last_n_weeks=8)` — compare the two halves to see if a recent campaign change moved the needle.

**Is my spend efficient?**
> "Which channels have the best ROI and which are wasting money?"

Uses `get_channel_roi` + `get_saturation_curves`. A channel can have high ROI (historically efficient) but high saturation (no room to grow). The combo tells you where you're getting value vs. where you're overspending.

**Budget planning for next quarter**
> "I have $50k/week. How should I split it across channels?"

Uses `get_marginal_roi` to rank channels by next-dollar efficiency, then `simulate_budget_reallocation` to test the proposed split. Iterate — "What if I move $5k from Facebook to Apple Search?" — and simulate again.

**Justifying a channel to leadership**
> "Should we keep spending on Roku? It's only 3% of contribution"

Uses `get_contribution_breakdown` + `get_channel_roi` + `get_adstock_parameters`. Maybe Roku has low contribution because it gets low budget, but its ROI is solid and it has long carry-over — it punches above its weight relative to spend.

**Flighting strategy**
> "Can we pause Vibe ads for 2 weeks without losing much?"

Uses `get_adstock_parameters`. If half-life is 2.5 weeks, the effect lingers — you can pulse. If Apple Search Ads has a 0.3-week half-life, pausing it kills performance immediately.

### For Data Scientists & Analysts

**Model sanity check**
> "Does this model make sense before I present it?"

Uses `get_model_summary` (convergence, divergences) → `get_channel_roi` (are ROIs plausible?) → `get_saturation_curves` (all channels at 90%+ saturation? suspicious) → `get_adstock_parameters` (absurdly wide CIs? model may be underidentified).

**Seasonality investigation**
> "Did our Q4 holiday spend actually drive more installs?"

Uses `get_weekly_contributions(start_week="2024-11-01", end_week="2025-01-15")` to see if contributions spiked during holiday weeks or if the extra spend just hit saturation.

**Comparing model versions**
> "We refit the model with 6 more months of data. What changed?"

Load model A, call `get_channel_roi` + `get_saturation_curves`. Switch `MERIDIAN_MODEL_PATH`, restart, run the same calls. Compare: did Facebook's ROI change? Did TikTok become more saturated?

**Sensitivity analysis**
> "How confident is the model in Google's ROI vs Snapchat's?"

Uses `get_channel_roi(credible_interval=0.9)`. If Google's CI is `[0.29, 1.18]` and Snapchat's is `[0.28, 1.19]`, the model can't distinguish them — important to flag before making allocation decisions.

**Diminishing returns mapping**
> "At what spend level does each channel hit the wall?"

Uses `get_saturation_curves` + `get_marginal_roi`. Saturation tells you where you are on the curve; mROI tells you what the next dollar returns. Together: "How much more can I spend on Apple Search before it stops being worth it?"

### For Executives & Strategy

**"What if" scenario planning**
> "What happens if we cut total budget by 20%?"

Uses `simulate_budget_reallocation` with all channels reduced proportionally. Then try a smarter cut: reduce saturated channels more, protect high-mROI ones. Compare the two scenarios.

**Budget-neutral optimization**
> "Same budget, better results — is it possible?"

Uses `get_marginal_roi` to find the gap between over-saturated and under-invested channels, then `simulate_budget_reallocation` to shift dollars from low-mROI to high-mROI. Even a budget-neutral reallocation can improve outcomes.

---

## How It Works

```
┌──────────────┐       stdio        ┌─────────────────────┐
│  Claude Code  │ ◄──────────────► │  mcp-server-meridian │
│  Claude Desktop│                  │                     │
│  Any MCP client│                  │  ┌───────────────┐  │
└──────────────┘                   │  │ Meridian .pkl  │  │
                                   │  │ (ArviZ trace)  │  │
                                   │  └───────────────┘  │
                                   └─────────────────────┘
```

At startup, the server loads a pickled `meridian.model.model.Meridian` object via `meridian_model.load_mmm()`. It extracts the ArviZ `InferenceData` trace and exposes tools that operate directly on the posterior samples — computing medians, credible intervals, and derived metrics (marginal ROI, saturation curves, budget projections) on the fly.

The server is **model-agnostic**: it dynamically reads channel names, time ranges, geographies, and parameter dimensions from the trace. Different clients' models with different channel sets, time windows, and geo granularity all work without configuration changes.

## Tools

### `get_model_summary`

Returns metadata about the loaded model — channels, time range, sample count, convergence diagnostics, and model configuration (adstock type, saturation type, prior settings). Call this first to understand what model is loaded.

**Parameters:** None

**Example output:**
```json
{
  "model_name": "meridian_demo_installs_app_installs_20260303",
  "n_chains": 7,
  "n_draws": 2000,
  "media_channels": ["facebook_ads", "google_ads", "tiktok_ads", "..."],
  "time_range": { "start": "2023-11-06", "end": "2025-10-27", "n_weeks": 104 },
  "adstock_type": "geometric",
  "max_lag": 4,
  "saturation_type": "hill",
  "convergence": { "has_divergences": false, "n_divergences": 0 }
}
```

---

### `get_channel_roi`

Returns the posterior ROI distribution for each channel — median, mean, credible intervals, and probability of positive ROI. Core metric for channel efficiency.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |
| `credible_interval` | float | 0.9 | Width of credible interval |

**Example output:**
```json
{
  "channels": [
    {
      "name": "facebook_ads",
      "roi_median": 0.376,
      "roi_mean": 0.399,
      "roi_ci_lower": 0.201,
      "roi_ci_upper": 0.676,
      "roi_std": 0.149,
      "prob_positive": 1.0
    }
  ],
  "n_samples": 14000
}
```

---

### `get_marginal_roi`

Returns the marginal ROI (mROI) — the expected return on the **next** dollar spent. Computed from the Hill curve derivative at current spend levels. Channels with mROI > average ROI have headroom; channels with mROI << average ROI are deep in saturation.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |
| `credible_interval` | float | 0.9 | Width of credible interval |

**Example output:**
```json
{
  "channels": [
    {
      "name": "facebook_ads",
      "mroi_median": 0.079,
      "mroi_mean": 0.097,
      "mroi_ci_lower": 0.009,
      "mroi_ci_upper": 0.247,
      "headroom_signal": "low",
      "vs_average_roi": -0.298
    }
  ]
}
```

---

### `get_contribution_breakdown`

Returns the modeled KPI contribution by channel — how much of total outcome each channel is responsible for. Computed from Hill saturation curves and beta coefficients at actual spend levels.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |
| `as_percentage` | bool | true | Include percentage of total |

**Example output:**
```json
{
  "channels": [
    {
      "name": "facebook_ads",
      "contribution_median": 18594.31,
      "contribution_ci_lower": 9295.11,
      "contribution_ci_upper": 35836.91,
      "contribution_pct": 16.47
    }
  ],
  "total_modeled_contribution_median": 114132.89
}
```

---

### `get_saturation_curves`

Returns Hill saturation curve parameters (EC, slope) for each channel and computes current saturation percentage. Answers: how much runway does each channel have before diminishing returns dominate?

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |

**Saturation labels:**

| Label | Range | Meaning |
|-------|-------|---------|
| `low` | < 40% | Significant headroom |
| `moderate` | 40–65% | Healthy operating range |
| `high` | 65–85% | Diminishing returns |
| `very_high` | > 85% | Near-ceiling, reallocate |

**Example output:**
```json
{
  "channels": [
    {
      "name": "tiktok_ads",
      "ec_median": 0.506,
      "slope_median": 1.0,
      "current_saturation_pct": 89.0,
      "saturation_label": "very_high",
      "interpretation": "At 89.0% saturation — incremental spend yields ~11.0% of peak efficiency."
    }
  ]
}
```

---

### `get_adstock_parameters`

Returns the geometric adstock (carry-over) decay rates for each channel — how long each channel's advertising effect persists after spend stops.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |

**Example output:**
```json
{
  "channels": [
    {
      "name": "tvscientific_ads",
      "decay_rate_median": 0.758,
      "decay_rate_ci_lower": 0.147,
      "decay_rate_ci_upper": 0.981,
      "half_life_weeks": 2.5,
      "effect_duration_weeks": 8.31,
      "interpretation": "Moderate carry-over — effect lingers for ~8 weeks. Can tolerate some gaps in spend."
    }
  ],
  "adstock_type": "geometric"
}
```

---

### `simulate_budget_reallocation`

Takes a proposed budget reallocation and projects the impact on KPI using the posterior's Hill + adstock curves. Channels not included in the proposal keep their current spend. This is the tool that powers closed-loop budget optimization.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `proposed_budgets` | dict[str, float] | **Required** | Channel name → new weekly spend |
| `n_weeks` | int | 4 | Projection horizon in weeks |
| `credible_interval` | float | 0.9 | Width of credible interval |

**Example input:**
```json
{
  "proposed_budgets": {
    "facebook_ads": 10000,
    "apple_search_ads": 35000,
    "tiktok_ads": 15000
  },
  "n_weeks": 4
}
```

**Example output:**
```json
{
  "current_total_weekly_spend": 65000,
  "proposed_total_weekly_spend": 67000,
  "spend_change_pct": 3.1,
  "projected_revenue": {
    "current_median": 114000,
    "proposed_median": 118000,
    "change_median": 4000,
    "change_pct": 3.5,
    "proposed_ci_lower": 95000,
    "proposed_ci_upper": 145000
  },
  "channel_impacts": [
    {
      "name": "facebook_ads",
      "current_spend": 15000,
      "proposed_spend": 10000,
      "spend_change_pct": -33.3,
      "current_contribution_median": 18594,
      "projected_contribution_median": 17200,
      "contribution_change": -1394
    }
  ],
  "optimization_notes": [
    "Reducing facebook_ads spend by 33% loses only 7% contribution — was in saturation zone."
  ]
}
```

### `get_weekly_contributions`

Returns a time series of per-channel contributions week by week. Use this to see recent performance, trends over time, or compare specific time windows.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `channels` | list[str] | All | Filter to specific channels |
| `last_n_weeks` | int | All | Return only the most recent N weeks (overrides start/end) |
| `start_week` | str | Earliest | Start date inclusive (ISO format, e.g. `"2025-06-01"`) |
| `end_week` | str | Latest | End date inclusive (ISO format) |

**Example output (`last_n_weeks=4`):**
```json
{
  "channels": [
    {
      "name": "facebook_ads",
      "weeks": ["2025-09-29", "2025-10-06", "2025-10-13", "2025-10-20"],
      "contribution_median": [185.3, 192.1, 178.9, 190.5],
      "contribution_ci_lower": [92.5, 96.0, 89.4, 95.2],
      "contribution_ci_upper": [356.8, 370.1, 345.2, 367.3],
      "total_median": 746.8
    }
  ],
  "n_weeks": 4,
  "time_range": { "start": "2025-09-29", "end": "2025-10-20" }
}
```

---

## Setup

### Option 1: Docker (recommended)

No Python or dependency setup required — just Docker.

```bash
# Build the image
docker build -t mcp-server-meridian servers/meridian

# Run with your model file
docker run -i --rm \
  -v /path/to/your/model.pkl:/models/model.pkl:ro \
  -e MERIDIAN_MODEL_PATH=/models/model.pkl \
  mcp-server-meridian
```

**Configure with Claude Desktop / Claude Code (Docker):**

```json
{
  "mcpServers": {
    "bluealpha-meridian": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-v", "/path/to/your/model.pkl:/models/model.pkl:ro",
        "-e", "MERIDIAN_MODEL_PATH=/models/model.pkl",
        "mcp-server-meridian"
      ]
    }
  }
}
```

### Option 2: Local (uv)

**Prerequisites:**
- Python 3.11 (required by Google Meridian)
- [uv](https://docs.astral.sh/uv/) for dependency management
- A fitted Meridian model pickle (`.pkl` file)

```bash
cd servers/meridian
uv sync
MERIDIAN_MODEL_PATH=/path/to/model.pkl uv run mcp-server-meridian
```

**Configure with Claude Desktop / Claude Code (local):**

```json
{
  "mcpServers": {
    "bluealpha-meridian": {
      "command": "uv",
      "args": ["--directory", "/path/to/servers/meridian", "run", "mcp-server-meridian"],
      "env": {
        "MERIDIAN_MODEL_PATH": "/path/to/meridian_model.pkl"
      }
    }
  }
}
```

To switch models, change the `MERIDIAN_MODEL_PATH` value and restart the server.

## Technical Details

### Model Pipeline

The server faithfully reproduces Meridian's internal computation pipeline:

1. **Scale** — Raw spend is normalized using the model's media transformer scale factors
2. **Adstock** — Geometric decay is applied (steady-state approximation: `x / (1 - α)`)
3. **Saturation** — Hill function: `x^s / (ec^s + x^s)` where `ec` is the half-saturation point and `s` is the slope
4. **Beta scaling** — Response is multiplied by the channel's effectiveness coefficient
5. **KPI transform** — Results are scaled from normalized space to real units using the KPI transformer's standard deviation

This order is dictated by the model's `hill_before_adstock = False` setting, which is read dynamically from the model spec.

### Posterior Sampling

All metrics are computed across the full posterior (all chains × all draws), producing proper Bayesian uncertainty estimates. Medians are used as point estimates; credible intervals are computed via percentiles.

### Key Posterior Variables

| Variable | Dims | Description |
|----------|------|-------------|
| `roi_m` | (chain, draw, media_channel) | ROI per channel |
| `alpha_m` | (chain, draw, media_channel) | Adstock decay rate |
| `beta_gm` | (chain, draw, geo, media_channel) | Media effectiveness coefficient |
| `ec_m` | (chain, draw, media_channel) | Hill half-saturation point |
| `slope_m` | (chain, draw, media_channel) | Hill curve slope |

## Known Limitations

This server was built and tested against national-level, single-KPI Meridian models with geometric adstock. The following assumptions are baked into the current implementation and may not hold for all Meridian models:

| Assumption | Impact | When it breaks |
|------------|--------|----------------|
| **Single geo** | `beta_gm` is sliced with `.isel(geo=0)` — only the first geo is used | Multi-geo models (regional, DMA-level) need aggregation across geos |
| **Geometric adstock only** | Steady-state formula `x / (1 - α)` assumes geometric decay | Models fitted with **Weibull** adstock use a different decay function |
| **`hill_before_adstock = False`** | Pipeline always runs adstock → Hill | Models with `hill_before_adstock = True` would need the reverse order; the flag is read in `get_model_summary` but not used to branch computation |
| **KPI scaling uses stdev only** | Inverse transform multiplies by `kpi_transformer._population_scaled_stdev` but ignores `_population_scaled_mean` | Models where the KPI mean offset is non-trivial will have biased contribution/simulation estimates |
| **`revenue_per_kpi` not applied** | The model's `revenue_per_kpi` tensor is never used | If your KPI is installs/leads and you want revenue-denominated outputs, this multiplier is missing |
| **Adstock type label hardcoded** | `get_model_summary` always reports `"adstock_type": "geometric"` | Cosmetic — doesn't affect computation, but would be misleading for Weibull models |

Contributions to address any of these are welcome — see [Development](#development) below.

## Development

```bash
uv sync --dev
uv run ruff check .
uv run pyright
uv run pytest
mcp dev src/mcp_server_meridian/server.py
```

## License

MIT
