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.
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 ageosfilter.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 arun_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 bycost_multipliers/revenue_per_kpi_multiplier/planned_allocation. Meridian does not forecast demand — this optimizes under assumptions, not a prediction. Same asyncrun_idlifecycle and management tools asrun_optimization.get_optimization_status— poll status:queued → running → completed/failed.get_optimization_result— structured result:summary,channel_tables,allocation,spend_delta,outcome_mode, andresponse_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.
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.
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.
gcloud+ Terraform>= 1.9installed;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).
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-central1cd 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 1terraform 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
applyfails duringgcloud builds submitwith an Artifact Registry permission error, grant the build service accountroles/artifactregistry.writer(or run one build manually to surface the exact principal), then re-runterraform apply.
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.)
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.
terraform destroy
gcloud storage rm -r gs://<state_bucket> # delete TF state bucket
# if the project was throwaway:
gcloud projects delete <project_id>This project targets Python 3.13.
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pippip install -e ".[dev]"Create .env in the repository root.
cp .env.example .envFor 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.
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.
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.
python -m google_meridian_mcp_server.serverFor 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/mcpfastmcp 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.
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-cpucloud_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.
Run tests:
uv run pytestRun Ruff:
uv run ruff check src tests scripts
uv run ruff format src tests scriptsBuild 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_validateThis 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.
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-serverThe container listens on 0.0.0.0 and respects the injected PORT environment variable.
When using the GCS backend, authenticate with Application Default Credentials locally:
gcloud auth application-default loginThen set these variables in .env:
PERSISTENCE_BACKEND=gcs
GCS_BUCKET=my-project.appspot.com
GCS_MODELS_PREFIX=models/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_metricspaid_summary_metricsroicpikmarginal_roimarginal_cpik
get_adstock_decay exposes:
adstock_decayalpha_summary
get_response_curves exposes:
response_curves, which returns numeric curve rows including spend, spend multiplier, metric, and incremental outcomeresponse_curve_summary, which returns numeric summarized rows keyed by channel, spend, and spend multiplier withmean,ci_lo, andci_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.
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.
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 |
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_smokeMIT — see LICENSE.