Skip to content

Repository files navigation

Google Meridian MCP Server [v0.3.2]

FastMCP server exposing a focused set of Google Meridian model-analysis and budget-optimization tools for agents.

This project wraps Google Meridian models behind a small MCP surface so agents can discover available models, inspect model setup, request structured analysis outputs, and submit long-running budget-optimization runs — without needing to understand Meridian's internal APIs directly.

It is designed for both local development and containerized deployment on Google Cloud Run, provisioned per client via Terraform.

Tools at a glance

Analysis

  • list_models — discover available fitted models.
  • get_model_overview — metadata: time range, geo scope, channel/input groups, valid output types for other tools.
  • get_training_data — raw training datasets (KPI, controls, population, spend).
  • get_channel_summary — ROI, CPIK, mROI, mCPIK, baseline and paid summary metrics per channel.
  • get_contribution — contribution decomposition by channel.
  • get_adstock_decay — adstock decay curves and alpha summaries.
  • get_response_curves — response curves and response curve summaries per channel.
  • get_model_fit — expected vs actual time series; honors a geos filter.
  • get_reach_frequency — optimal-frequency ROI curves (RF models only).
  • get_channel_data — per-channel long table across all channel types.
  • get_spend_scenario — what-if spend change: ROI/mROI or CPIK/mCPIK at new spend level.

Optimization

  • run_optimization — submit a historical fixed-budget or target-ROAS/mROAS run (returns immediately with a run_id).
  • run_future_optimization — optimize a future budget under user-supplied assumptions: carries forward cost-per-media-unit, flighting, and revenue-per-KPI from a chosen reference window (trailing / same_period_last_year / full_history_average), optionally scaled by cost_multipliers / revenue_per_kpi_multiplier / planned_allocation. Meridian does not forecast demand — this optimizes under assumptions, not a prediction. Same async run_id lifecycle and management tools as run_optimization.
  • get_optimization_status — poll status: queued → running → completed/failed.
  • get_optimization_result — structured result: summary, channel_tables, allocation, spend_delta, outcome_mode, and response_curves (historical runs only — future runs omit curves, since Meridian cannot recompute them under future assumptions).
  • list_optimizations — list runs for a model with optional status filter.
  • delete_optimization — remove a completed or failed run from the registry.
  • cancel_optimization — best-effort cancel of a queued or running run.

Bundled skill

The server bundles a meridian-analyst Agent Skill. When a connecting client supports the MCP skills provider, it's discoverable over the resource URI skill://meridian-analyst/SKILL.md.

For clients that don't yet surface MCP skill resources, use the folder-drop fallback: copy the skills/meridian-analyst/ directory verbatim into the client's skills folder (e.g. .claude/skills/). It's a standards-compliant Agent Skill (agentskills.io format) and needs no conversion.

The skill teaches orchestration, model taxonomy, budget optimization and reallocation (both historical run_optimization and future run_future_optimization), and channel-performance workflows.

It includes a consultative guidance layer (references/consultation.md) for the common case where a non-expert user (marketer, CMO) asks a vague, high-level question ("optimize my Q4 budget", "where should I put more money?"). Instead of running on silent defaults, the skill has the agent: (1) elicit only the genuinely-unknowable gaps first — goal, budget change, hard constraints, the future period — in plain business language (no cpmu/flighting/pct_of_spend jargon); (2) propose a concrete plan with every remaining assumption named; (3) confirm before running when the request is ambiguous or high-stakes. It also carries a plain-language → tool-field translation table (e.g. "TV CPMs up ~15%" → cost_multipliers {TV: 1.15}; "plan like last December" → future tool, same_period_last_year reference).

It does not replace the per-tool descriptions above, which remain the source of truth for parameters.

Deploy to Google Cloud (Terraform)

Architecture

A single terraform apply builds and pushes all three images via Cloud Build (content-hash tags), then provisions Artifact Registry, GCS, the Cloud Run Service (MCP server), and the Cloud Run Jobs (CPU worker; GPU opt-in). Per-client inputs (terraform.tfvars, backend.hcl) are never committed. GPU is opt-in (enable_gpu_job = true + optimization_tier = "cloud_gpu" (or "cloud_auto") + L4 quota in the region). The default apply provisions the CPU worker only.

Service account: the service and jobs run as a single identity. By default (service_account_id unset) that is the project's compute engine default service account and Terraform creates/binds nothing — it relies on that SA's project Editor grant. Set service_account_id to a name (e.g. meridian-mcp) and Terraform instead creates or adopts a dedicated SA in the project and grants it least-privilege roles (run.developer, storage.objectAdmin, and actAs on itself). terraform output service_account reports which identity is in use. This replaces the previous two-SA (meridian-mcp-server + meridian-opt-worker) layout. Because the dedicated SA is created by an in-apply gcloud step (mirroring the image build), terraform destroy removes its role bindings but leaves the SA itself in place — just as it leaves built images in Artifact Registry; delete it manually with gcloud iam service-accounts delete if you want it gone.

Prerequisites

  • gcloud + Terraform >= 1.9 installed; gcloud auth application-default login.
  • An existing GCP project (project_id) with billing linked.
  • A GCS bucket for Terraform state (bootstrap below).
  • At least one fitted Meridian model uploaded under gs://<bucket>/<models_prefix>.
  • Apply runs from a full repo checkout (the Dockerfiles and src/ are the Cloud Build context).

1. Bootstrap (once per client)

gcloud projects create <project_id>              # or use an existing one
gcloud billing projects link <project_id> --billing-account <ACCOUNT_ID>
gcloud storage buckets create gs://<state_bucket> --project <project_id> --location us-central1

2. Configure (uncommitted)

cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars   # fill project_id, gcs_bucket, sizing
cp backend.hcl.example backend.hcl             # the state bucket from step 1

3. Provision

terraform init -backend-config=backend.hcl
terraform apply       # builds all 3 images via Cloud Build, then provisions everything
terraform output service_uri   # MCP endpoint base; append /mcp (no trailing slash)

The first apply is long — the default CPU-only apply runs two Cloud Builds in parallel (the server image and the multi-GB opt-cpu worker; a third opt-gpu build is added when enable_gpu_job = true). The worker image can take 10–40 minutes to build (the Cloud Build timeout is 40 min). If a build fails mid-apply, re-running terraform apply resumes cleanly (it is idempotent).

On a brand-new project the Cloud Build service account may lack push access to Artifact Registry. If apply fails during gcloud builds submit with an Artifact Registry permission error, grant the build service account roles/artifactregistry.writer (or run one build manually to surface the exact principal), then re-run terraform apply.

4. Smoke-test the deployed server

uv run python -m scripts.validation.remote_smoke --url "$(terraform output -raw service_uri)"
# end-to-end incl. a real cloud optimization (submit -> poll -> pull result):
uv run python -m scripts.validation.remote_smoke --url "$(terraform output -raw service_uri)" --run-optimization

(Requires allow_unauthenticated = true, or auth in front of the service.)

Onboarding another client

Repeat with a different project_id, gcs_bucket, and a different backend.hcl (state bucket in that client's project). Same code, different uncommitted inputs, isolated state.

Teardown

terraform destroy
gcloud storage rm -r gs://<state_bucket>     # delete TF state bucket
# if the project was throwaway:
gcloud projects delete <project_id>

Local development

Setup

1. Create a Python environment

This project targets Python 3.13.

python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip

2. Install the project

pip install -e ".[dev]"

3. Configure .env

Create .env in the repository root.

cp .env.example .env

For local filesystem-backed development, a minimal .env looks like this:

MCP_TRANSPORT=streamable-http
MCP_HOST=127.0.0.1
PERSISTENCE_BACKEND=local
LOCAL_MODELS_ROOT=./models
OPTIMIZATION_TIER=local

.env belongs at the project root because the runtime loads it from there explicitly.

Migrating an existing .env

load_dotenv silently ignores keys it doesn't recognize, so a stale .env from before this change degrades to defaults instead of failing at startup. Update these by hand:

Old New
OPTIMIZATION_ALLOWED_TIERS=cloud_cpu OPTIMIZATION_TIER=cloud_cpu
OPTIMIZATION_ALLOWED_TIERS=local,cloud_cpu pick one; the mixed form never worked
OPTIMIZATION_DEFAULT_TIER=... delete; it never influenced routing
REGISTRY_BACKEND=... delete; the run registry follows PERSISTENCE_BACKEND
MCP_PORT=... PORT=...
ANALYSIS_MAX_PARALLEL, ANALYSIS_QUEUE_WAIT_TIMEOUT delete
DISCOVERY_TTL_SECONDS, OPTIMIZATION_SIZE_THRESHOLDS, OPTIMIZATION_HEARTBEAT_STALE_SECONDS, ANALYSIS_WORKDIR_ROOT, ANALYSIS_WORKDIR_TTL_SECONDS delete; now module constants
ANALYSIS_MAX_RESPONSE_BYTES=... delete; no response is capped any more

One case does not degrade benignly. A cloud operator whose .env still says OPTIMIZATION_ALLOWED_TIERS=cloud_cpu gets OPTIMIZATION_TIER unset, hence local. That is fully legal under the new validation — the local tier imposes no requirements, and gcs persistence with a local optimization tier is supported — so startup cannot catch it. The server then runs every optimization as a subprocess on the Cloud Run instance, surfacing as OOM or a request timeout rather than a config error.

Add a model

Both flat and nested layouts are supported. Nested directories are usually clearer.

models/
├── geo-revenue/
│   └── model.binpb
└── experiment-a/
    └── model.binpb

The catalog will expose those examples as model IDs like geo-revenue and experiment-a.

Models must be Meridian's proto format (.binpb). Pickle (.pkl) checkpoints are not supported -- see reports/pkl-format-removed.md. Re-export a pickle model to .binpb and load that instead.

Run the server

python -m google_meridian_mcp_server.server

MCP Inspector

For interactive Inspector testing, the repository includes fastmcp.json. The most reliable way to test with your local environment is to start the server yourself and connect the Inspector to http://localhost:8000/mcp:

source .venv/bin/activate
python -m google_meridian_mcp_server.server
# then open MCP Inspector and connect to http://localhost:8000/mcp

fastmcp dev inspector does not use your activated .venv or Conda environment directly. FastMCP launches Inspector servers through a uv run subprocess, so inspector-specific dependencies must be declared in fastmcp.json or passed with CLI flags such as --project, --with-editable, and --with.

Local optimization tier

By default OPTIMIZATION_TIER=local with PERSISTENCE_BACKEND=local — nothing else needed beyond the defaults in .env.example.

To offload to Cloud Run Jobs:

PERSISTENCE_BACKEND=gcs
GCS_BUCKET=<bucket>
GCS_MODELS_PREFIX=models/
OPTIMIZATION_TIER=cloud_cpu
CLOUD_RUN_PROJECT=<project_id>
CLOUD_RUN_REGION=us-central1
CLOUD_RUN_JOB_CPU=meridian-opt-cpu

cloud_gpu swaps CLOUD_RUN_JOB_CPU for CLOUD_RUN_JOB_GPU; cloud_auto needs both. A cloud tier requires PERSISTENCE_BACKEND=gcs — a Cloud Run Job worker cannot read the server's local disk, so it needs the run registry and models in GCS.

Cloud tiers use a JAX backend (workers run inside a Cloud Run Job execution). Both the CPU tier and the GPU tier (NVIDIA L4) have run a real optimization end-to-end on Cloud Run (one fixture, one run per tier; CPU ~50s compute, GPU ~65s compute) — functional verification only, not a performance comparison between them.

Quality checks

Run tests:

uv run pytest

Run Ruff:

uv run ruff check src tests scripts
uv run ruff format src tests scripts

Live validation

Build dummy models for every variant and validate every tool live against an in-process MCP client (national vs geo, revenue vs KPI, with adversarial error-path checks):

uv run python -m scripts.validation.live_validate

This generates gitignored fixtures under models/_validation/ on first run and exits non-zero on any mismatch.

The reports/ directory holds committed evidence from past verification work (drift reports, refit notes, format-support decisions, and a running list of what verification did not cover); see reports/README.md for an index.

Docker (local container)

Build locally:

docker build -t google-meridian-mcp-server .

Run locally in Docker:

docker run --rm -p 8080:8080 --env-file .env -e MCP_HOST=0.0.0.0 google-meridian-mcp-server

The container listens on 0.0.0.0 and respects the injected PORT environment variable.

GCS backend notes

When using the GCS backend, authenticate with Application Default Credentials locally:

gcloud auth application-default login

Then set these variables in .env:

PERSISTENCE_BACKEND=gcs
GCS_BUCKET=my-project.appspot.com
GCS_MODELS_PREFIX=models/

Reference

Tool surface

Every tool is annotated as read-only and uses typed parameters with documented validation metadata so the generated schema is stricter and easier for agents to call correctly.

Response envelope

Tool responses are canonical JSON payloads. The row-oriented analysis tools return a compact columnar envelope: model_id, a selector field (output_type for analysis tools, or datasets/dataset for training data), columns (the ordered column names), rows (a list of positional value lists, one per row), and row_count. There is no data key and no result_metadata block. Measure floats are rounded to 6 significant figures.

Grouped analysis tools return posterior-only rows. Prior rows are removed from tool results, and the transport payloads do not include a distribution field.

Two optional envelope keys sit after the leading identity keys and before the columnar payload. ignored_filters maps a supplied-but-unhonored filter to the reason its output type cannot honor it; it appears only when the caller actually supplied such a filter. scope is emitted unconditionally, whether or not any filters were passed, and only by get_adstock_decay's adstock_decay and alpha_summary output types, since adstock alpha is a national, time-invariant posterior parameter regardless of any date or geo filter.

Payload location. Each tool response carries its payload exactly once, in structuredContent. The content block holds a short human-readable note (for row-bearing results, "<n> rows x <m> columns in structuredContent"), never the data itself. Client authors must read structuredContent — or the SDK's .data accessor — and must not parse content[0].text as JSON. list_models is the one tool whose payload is wrapped as {"result": [...]}.

Per-tool notes

get_model_overview returns the model's time range, geo scope, channel/input groups, flattened data schema, and the supported dataset/output-type values for the other analysis tools.

get_training_data accepts one or more dataset keys and returns a single merged result set for the requested selections. Pass a dataset or date filter for anything but small models: the unfiltered, all-datasets call returns 3.2 MB for national-revenue and 16.3 MB for geo-revenue, which the transport delivers correctly but which is far more than an agent can hold in context.

get_channel_summary exposes:

  • baseline_summary_metrics
  • paid_summary_metrics
  • roi
  • cpik
  • marginal_roi
  • marginal_cpik

get_adstock_decay exposes:

  • adstock_decay
  • alpha_summary

get_response_curves exposes:

  • response_curves, which returns numeric curve rows including spend, spend multiplier, metric, and incremental outcome
  • response_curve_summary, which returns numeric summarized rows keyed by channel, spend, and spend multiplier with mean, ci_lo, and ci_hi

get_model_fit returns expected vs actual outcome values alongside baseline and residual series so agents can assess time-series model accuracy. Pass a geos filter to fit only selected markets; results are aggregated to one national series (per-geo breakdown is not returned) using Meridian's own ModelFit visualizer, so they match the showcase app. An unknown geo raises missing_model_data.

get_reach_frequency returns optimal-frequency ROI curves for reach & frequency channels; it raises metric_not_supported on models that have no RF channels.

get_channel_data returns a per-channel long table covering all channel types (paid media, RF, organic media, organic RF, and non-media), useful for inspecting raw spend and impression inputs.

get_spend_scenario simulates a what-if change to one channel's spend (a per-time-unit increment, with an optional explicit base spend) and returns the channel's efficiency at the base and new spend levels — ROI/mROI for revenue models, CPIK/mCPIK for KPI-only models.

Note: roi and marginal_roi output types are only available for revenue models (those with a non-null revenue_per_kpi). On KPI-only models, requesting these metrics raises metric_not_supported. cpik and marginal_cpik are valid for all model types.

Terraform variables

Images are built and tagged automatically (content hash) — there are no image variables. Server/worker sizing and job names have fixed defaults in the module (modules/meridian-stack/variables.tf).

Variable Default Description
project_id (required) Existing GCP project to provision into.
region us-central1 Region for all regional resources.
service_account_id "" Name of a dedicated service account to create/adopt for the service and jobs (e.g. meridian-mcp); empty uses the project's compute engine default SA.
gcs_bucket (required) Bucket holding fitted models and optimization run files.
create_bucket true Create the bucket here, or reference an existing one.
bucket_force_destroy false Allow destroy to delete a non-empty bucket (throwaway installs only).
gcs_models_prefix models/ Key prefix where fitted models live.
optimization_gcs_prefix optimizations/ Key prefix for optimization run files.
artifact_registry_repo meridian Artifact Registry docker repository id.
enable_gpu_job false Provision the GPU (L4) worker. Set true AND optimization_tier = "cloud_gpu" (or "cloud_auto") AND ensure L4 quota.
optimization_tier cloud_cpu Where this deployment runs optimizations: local | cloud_cpu | cloud_gpu | cloud_auto.
allow_unauthenticated false Grant roles/run.invoker to allUsers (live tooling test only; gate behind auth for real clients).
result_cache_enabled true Whether the server caches analysis results (sets RESULT_CACHE_ENABLED). Leave true for real client installs; set false only for verification work needing cold, uncached responses.
labels {} Labels applied to created resources.
optimization_max_parallel 2 Max concurrent Cloud Run Job executions this server instance has launched and not yet observed the completion of. Per instance, so with max_instance_count = 2 the effective ceiling is twice this. Over-cap runs queue durably and are recovered at the next instance start.
analysis_worker_timeout 300 Seconds an analysis worker may run before the request fails as worker_timeout.

Breaking change: Existing configurations with optimization_tier = "cloud_gpu" (or "cloud_auto") and enable_gpu_job = false are now rejected at plan time. That combination previously applied cleanly and failed later as an opaque worker_lost. Set enable_gpu_job = true and ensure L4 quota, or switch to optimization_tier = "cloud_cpu".

The service's request timeout is derived as analysis_worker_timeout + 30s, so the two cannot drift and a worker timeout always reaches the client as an actionable worker_timeout envelope rather than a dropped connection.

Analysis concurrency is the operator's job. The server imposes no in-process limit on concurrent analysis requests — the semaphore and ANALYSIS_MAX_PARALLEL are gone, and concurrency is now a deployment policy, not a library one. Peak memory is therefore N concurrent requests × (framework + full model); sizing that is the operator's responsibility. On Cloud Run the backpressure knob is max_instance_request_concurrency on the service, which this stack leaves unset — so Cloud Run's default of 80 requests per instance applies, against max_instance_count = 2 (cloud_run_service.tf:12-13). An operator who wants a bound should set that field; it is the right layer for it. OPTIMIZATION_MAX_PARALLEL (optimization_max_parallel above) is the contrast: it is the only in-process concurrency bound left, and it bounds optimization worker launches, not analysis requests.

Worker environment contract

Set in the Terraform-managed job definition:

Variable Description
PERSISTENCE_BACKEND Always gcs for cloud workers
GCS_BUCKET Bucket for model storage and optimization run files
GCS_MODELS_PREFIX Prefix where fitted models are stored
OPTIMIZATION_GCS_PREFIX Prefix for optimization run manifests/state/results

Injected fresh per execution by the MCP server's CloudRunJobExecutor:

Variable Description
OPTIMIZATION_RUN_ID UUID of the run to execute
MERIDIAN_BACKEND Always jax; injected from the module constant in execution/base_subprocess.py
MERIDIAN_ENABLE_JAX_X64 Always true; 64-bit precision, pinned rather than inherited

Optimization tiers & concepts

The optimization tools submit and track long-running Meridian BudgetOptimizer runs.

Tiers

Tier Runs on Use
local Subprocess (default) Local development; no GCP required.
cloud_cpu Cloud Run Job (CPU) Production runs; requires PERSISTENCE_BACKEND=gcs.
cloud_gpu Cloud Run Job (NVIDIA L4) Large or fast runs; requires PERSISTENCE_BACKEND=gcs, enable_gpu_job = true, and L4 quota.

On the cloud tiers a submitted run survives a server restart or an instance scale-down: queued runs are recorded durably and recovered when an instance next starts, and executions already in flight are re-adopted rather than relaunched. OPTIMIZATION_MAX_PARALLEL bounds concurrent job launches per instance, so a submission over the cap waits rather than failing.

Every tier runs Meridian on the JAX backend with 64-bit precision. There is no per-tier engine choice: OPTIMIZATION_BACKEND_LOCAL / OPTIMIZATION_BACKEND_CLOUD_CPU / OPTIMIZATION_BACKEND_CLOUD_GPU were removed in the Meridian 2.0 upgrade.

To reach a specific tier, pass compute_tier on the tool call. auto runs wherever the deployment is configured to run (OPTIMIZATION_TIER): under local, cloud_cpu or cloud_gpu, auto is just that tier. Only under cloud_auto does problem size choose anything, and then only between CPU and GPU.

Which tier does cloud_auto pick between CPU and GPU?

Selection multiplies geos × time_periods × channels × posterior_samples and compares it to the single module constant _GPU_SIZE_THRESHOLD (1e8) in execution/routing.py — not an env var. Controls, KPI, and spend columns do not affect it; channels = paid media + reach/frequency channels.

The grid below assumes a typical model — weekly data over ~2 years (~104 periods) and 7,000 posterior samples (7 chains × 1,000 draws) — and shows what cloud_auto picks between the two cloud tiers:

channels ↓ \ geos → 1 (national) 5 10 25 50 100
5 cloud_cpu cloud_cpu cloud_cpu cloud_cpu cloud_gpu cloud_gpu
8 cloud_cpu cloud_cpu cloud_cpu cloud_gpu cloud_gpu cloud_gpu
10 cloud_cpu cloud_cpu cloud_cpu cloud_gpu cloud_gpu cloud_gpu
15 cloud_cpu cloud_cpu cloud_gpu cloud_gpu cloud_gpu cloud_gpu
20 cloud_cpu cloud_cpu cloud_gpu cloud_gpu cloud_gpu cloud_gpu

Rule of thumb (this horizon and sampling): cloud_gpu when geos × channels ≳ 137, and cloud_cpu below that. Other cadences scale the boundary: 3-year weekly (~156 periods) tips to cloud_gpu at geos × channels ≳ 92; monthly data keeps far more models on cloud_cpu. Longer histories or more posterior draws push runs toward cloud_gpu.

Validation gates

# Local gate (no real GCP project needed — runs full cloud launch/liveness/cancel contract with a fake):
uv run python -m scripts.validation.live_validate

# Real Cloud Run smoke (requires CLOUD_SMOKE=1, MODEL_ID, and a configured .env with cloud tiers):
CLOUD_SMOKE=1 COMPUTE_TIER=cloud_cpu MODEL_ID=<model_id> uv run python -m scripts.validation.cloud_smoke

License

MIT — see LICENSE.

About

FastMCP server exposing a focused set of Google Meridian model-analysis tools for agents.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages