Skip to main content
Glama
hyoon2007

mpulse-mcp

by hyoon2007
README.md
# mpulse-mcp

An MCP (Model Context Protocol) server that wraps the **Akamai mPulse Query
API v2** and exposes it as tools for Claude Desktop (local **stdio**). It fetches
mPulse RUM (Real User Monitoring) aggregate data — summaries, histograms, and
per-minute time series — and returns **loss-free** numbers suitable for
downstream p75 / statistical analysis.

- **Accuracy first** — endpoints, parameters, and schemas were confirmed against
  the live mPulse docs before implementation (see
  [Verified API contract](#verified-api-contract)).
- **Loss-free data** — numeric values are never rounded, summarized, or dropped.
- **stdio-safe** — nothing is ever written to stdout (that would corrupt the
  JSON-RPC stream); all logs go to **stderr**. Secrets are masked in logs.

---

## Verified API contract

Fetched from `techdocs.akamai.com/mpulse` during implementation. The table below
records what was confirmed and any **differences from the original brief's
assumed defaults**.

| Item | Confirmed value |
|---|---|
| Query base host | `https://mpulse.soasta.com` ✅ matches |
| Query URL format | `/concerto/mpulse/api/v2/<api-key>/<query-type>?<params>` ✅ matches |
| Auth header | **`Authentication: <token>`** (not `Authorization`) ✅ matches |
| Common param | `format=json` (the default `legacy` format is deprecated) ✅ |
| Token endpoint | **`PUT /concerto/services/rest/RepositoryService/v1/Tokens`** |
| Token request body | `{ "apiToken": "<pre-issued>", "tenant": "<tenant>" }` (API-token/SSO flow; no username/password) |
| Token response | `{ "token": "<security token>" }` |
| Token lifetime | **expires after 5 hours of inactivity** |
| Data characteristics | per-minute aggregation, **one calendar day per query**, timezone-dependent |

**Confirmed query-type slugs → explicit tools**

| Tool | query-type slug | Response core |
|---|---|---|
| `get_summary` | `summary` | `median, moe, n, p95, p98` |
| `get_histogram` | `histogram` | `series.series[].buckets/aPoints, median, p95, p98` |
| `get_timers` | `by-minute` | `series.series[].aPoints[{x,y}], statistics` (single timer) |
| `get_metrics` | `timers-metrics` | `dataTimeZone, values[{id, history[], latest}]` (multiple) |

Other confirmed slugs (reachable via the generic `query` tool):
`dimension-values`, `dimension-over-time`, `metrics-by-dimension`,
`metric-per-page-load-time`, `sessions-per-page-load-time`, `app-error-summary`,
`page-groups`, `browsers`, `ab-tests`, `bandwidth`, `geography`.

**Confirmed parameters**: `timer` (default `PageLoad`) / `custom-timer`,
`metric` (default `Beacons`), `percentile` (1–99, default 50), `date`
(`YYYY-MM-DD`), `date-comparator` (`Last30Minutes`, `LastHour`, `Last24Hours`,
`ThisWeek`, `Last` + `trailing-seconds`, `Between` + `date-start`/`date-end`),
`timezone` (Java TZ id, default UTC).

**Confirmed drilldown params**: `page-group`, `browser`, `browser-family`,
`ab-test`, `country` (ISO alpha-2), `region`, `device-type`, `device-model`,
`device-manufacturer`, `os`, `os-family`, `connection-type`, `isp`,
`bandwidth-block`, `beacon-type`, `site-version`, `custom-dimension-{label}`.
**Unsupported drilldown combinations return empty data, not an error** — the
server flags this in the result `note`.

### Differences from the brief's assumed defaults

1. **`date_comparator` → wire name `date-comparator`** (hyphen). Tool arguments
   use underscores and are mapped to the hyphenated wire names automatically.
2. **Token lifetime is "5 hours of inactivity"**, not a very-short token. The
   server still refreshes proactively (soft 4h TTL) and reactively on a query
   `401`, so correctness does not depend on the exact figure.
3. **Rate-limit numbers (concurrent 3 / 100 per-min / 10k per-hour / 50k
   per-day) could not be re-confirmed** from the doc pages fetched. They are
   adopted from the brief and kept **configurable** as constants in
   `client.py` (`MAX_CONCURRENCY`, `PER_MINUTE_LIMIT`, retry settings). The
   per-hour/per-day caps are advisory (not separately throttled).

---

## Install

Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).

```bash
uv sync --extra dev
```

## Configure

Secrets go in the environment; the non-secret app registry goes in a JSON file.

1. **Registry** — copy the example and fill in your api keys / tenant:

   ```bash
   cp mpulse_apps.example.json mpulse_apps.json
   ```

   ```json
   {
     "default_app": "app-a",
     "tenant": "your-tenant",
     "api_token_env": "MPULSE_API_TOKEN",
     "apps": {
       "app-a":  { "api_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" },
       "app-b": { "api_key": "YYYYY-YYYYY-YYYYY-YYYYY-YYYYY",
                          "tenant": "override-if-different",
                          "api_token_env": "MPULSE_API_TOKEN_APP_B" }
     }
   }
   ```

   Resolution: an app's `tenant` / `api_token_env` override the top-level
   defaults; `api_key` is required per app. The registry path is found via
   `MPULSE_APPS_CONFIG`, else `./mpulse_apps.json`.

   **Custom dimensions (optional).** mPulse has no API to discover an app's
   custom dimensions, so declare them here to make them usable without guessing.
   Add a `custom_dimensions` object per app (top-level entries are inherited by
   every app and merged with the app's own):

   ```json
   "custom_dimensions": { "branch": { "display": "Branch" } },
   "apps": {
     "app-a": {
       "api_key": "…",
       "custom_dimensions": {
         "mobile_speed": { "display": "mobile speed", "description": "effectiveType" },
         "campaign": {}
       }
     },
     "app-b": { "api_key": "…", "custom_dimensions": ["checkout_step"] }
   }
   ```

   Keys are the mPulse **wire label** (lowercased, spaces → `_`); a value may be
   `null`, a `{display, description, values?}` object, or you may pass a plain
   list of names. Used as a split (`metrics-by-dimension dimension=<label>`) or a
   filter (`custom-dimension-<label>=<value>`). These are **hints only** (never
   used to reject), since the declared list may be incomplete.

2. **Secrets** — set the pre-issued mPulse **API token(s)** (mPulse → your name
   → *Account* → *Generate/Revoke API Token*). See `.env.example`:

   ```bash
   MPULSE_API_TOKEN=your-pre-issued-api-token
   # MPULSE_API_TOKEN_APP_B=...   # only if an app overrides api_token_env
   ```

   The short-lived **security token** used on each request is minted at runtime
   from the API token + tenant — you never manage it yourself.

   **Where does `.env` go?** For local runs and the smoke test, place `.env` in
   the project root (the folder with `pyproject.toml`, i.e. the working
   directory). It is auto-loaded at startup — also from the directory of
   `MPULSE_APPS_CONFIG`. Existing environment variables are never overridden.
   **For Claude Desktop, do not use `.env`** — put secrets in the `env` block of
   `claude_desktop_config.json` (below); that is what Claude Desktop injects, and
   it takes precedence over any `.env`.

## Run

```bash
uv run mpulse-mcp        # or: uv run python -m mpulse_mcp
```

Transport is stdio; the process speaks MCP on stdout and logs to stderr.

## Register in Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "mpulse": {
      "command": "uv",
      "args": ["--directory", "/Users/hyoon/Projects/mpulse-mcp", "run", "mpulse-mcp"],
      "env": {
        "MPULSE_API_TOKEN": "your-pre-issued-api-token",
        "MPULSE_APPS_CONFIG": "/Users/hyoon/Projects/mpulse-mcp/mpulse_apps.json"
      }
    }
  }
}
```

Restart Claude Desktop; the mPulse tools appear in the tools menu.

## Tools

**Explicit** (typed, validated): `get_summary`, `get_histogram`, `get_timers`
(single timer, per-minute), `get_metrics` (multiple timers/metrics, per-minute).
Each accepts `app` (optional → default app), **exactly one** of `date`
(`YYYY-MM-DD`) or `date_comparator`, optional `timezone`, drilldown filters
(`page_group`, `browser`, `ab_test`, `country`, `device_type`, …), and
`raw` (default `false`).

**Batch**: `get_aggregate(metrics=[...], timers=[...], periods=[...],
percentiles=[50,75], …)` — a `(metric|timer) × period × percentile` matrix in
one call. Collapses the common reporting workflow (e.g. 3 metrics × 2 months ×
p50/p75 = 12 calls) into a small table of `latest` scalars (no history). Each
cell fails independently (`errors[]`); `max_combos` (default 24) caps the
fan-out; all calls share the rate limiter. See [Batch aggregate](#batch-aggregate).

**Generic**: `query(query_type, app=None, params={...}, raw=False)` — any
query-type with wire-name params, for cases the explicit tools don't cover.

**Meta** (anti-hallucination): `list_apps`, `list_query_types`,
`describe_query(query_type, app=None)` (pass `app` to include that app's custom
dimensions), `list_custom_dimensions(app=None)`.

### Output format

- `raw=false` (default): a flat envelope — `app`, `query_type`, `period`,
  `timezone`, `aggregation`, `drilldowns`, `empty`, `history_mode`, and `body`
  (the envelope-stripped data). Empty results are flagged, with a note when an
  unsupported drilldown combination is the likely cause.
- `raw=true`: mPulse's untouched JSON under `data`.

**`history_mode` — per-minute time-series volume (loss-free on demand).** The
temporal query-types `timers-metrics` and `by-minute` return ~1,440 points/day,
of which the common workload uses only the period aggregate. `history_mode`
(on `get_timers`, `get_metrics`, and `query`) controls this:

| mode | time series | always kept | ~size |
|---|---|---|---|
| `downsample` (**default**) | ≤60 even-spaced points + `peak` | `latest`/`statistics`, `n_points` | ~1/20 |
| `none` | dropped (endpoints only) | `latest`/`statistics`, `n_points`, `peak` | ~1/100 |
| `full` | every minute (loss-free) | everything | full |

The complete series is always recoverable via `history_mode="full"` or
`raw=true`. **Aggregates are never reduced**: `latest` (period value — read this
for monthly p75, don't average the history), mPulse `statistics`, `histogram`
buckets, and `summary` are kept in full regardless of mode. A `peak`
(`{index/x, value/y}`) is added so "when did it spike?" survives downsampling.

**`timer`/`metric` name resolution (input side).** mPulse silently answers an
unrecognized `timer`/`metric` with a *default* (PageLoad) series instead of
erroring, so the explicit tools resolve these names against the catalog before
calling the API:

- a casing/separator-only mismatch (`largest_contentful_paint` →
  `LargestContentfulPaint`, endpoint-specific) is **auto-corrected** silently
  (zero extra round-trips); the fix is reported in a `corrections` field;
- a genuinely unknown name is **rejected** with close suggestions
  (`Did you mean: …?`) — the wrong name never reaches mPulse;
- custom timers/dimensions and the generic `query` tool are left permissive
  (forward-compatible); if the catalog is unavailable, resolution is skipped.

**Custom `dimension` support is endpoint-specific.** A custom dimension (e.g.
`branch`) is accepted **only** by `metrics-by-dimension` (as its `dimension`
split value). `dimension-values` and `dimension-over-time` accept **built-in
dimensions only** — a custom name there returns empty/400 (a wasted call). The
`query` tool enforces this: a custom `dimension` on those two query-types is
**rejected up front** with a redirect to `metrics-by-dimension`, while a
built-in's casing is auto-corrected. `describe_query` reports
`custom_dimension_supported` per query-type so the model knows before calling.

For an app's *declared* custom dimensions (see Configure → Custom dimensions),
the `query` tool additionally normalizes the label to its wire form
(`mobile speed` → `mobile_speed`) for both the split value and
`custom-dimension-<label>` filters, and adds a soft `dimension_notes` advisory
when a name isn't in the declared set — but always passes it through (the
declared list may be incomplete). `list_custom_dimensions(app)` and
`describe_query(query_type, app)` expose the declared names so the model uses
the exact label instead of guessing.

**Passing multiple values (mechanism differs per parameter).** Verified against
every endpoint's reference doc:

- **Comma-separated, one param** — only `metrics` on `metrics-by-dimension`
  (**plural** param name; the singular `metric` is silently ignored). Pass a
  pre-joined string: `"metrics": "beacons,largest_contentful_paint"`.
- **Repeated key** — `metric`/`timer` on `timers-metrics` and **every drilldown
  filter** (`country`, `browser`, `ab-test`, …) and `custom-dimension-<label>`.
  Pass a **JSON list** and the client expands it to repeated query keys:
  `"custom-dimension-branch": ["uk","us","sec"]` →
  `custom-dimension-branch=uk&…=us&…=sec` (verified live). A Python-list-repr no
  longer leaks onto the wire.
- **Single value** — `dimension`, `percentile`, `metric` on
  `metric-per-page-load-time` (its own enum: `BounceRate` + `CustomMetric0-9`),
  etc.

`describe_query` surfaces a `parameter_mechanics` block (which params are
comma-separated vs repeat-key) plus an `endpoint_params` block — the
**per-endpoint** contract compiled from every reference page: value params with
defaults, special params (`limit`, `sortby`, `interval`, `series-format`) with
ranges, whether drilldown filters apply, and constraints. So the model knows,
before calling, e.g. that `metrics-by-dimension` takes native `sortby`/`limit`
(1-100, not valid for page_group/browser/country/bw_block/ab_test), computes
percentiles, and returns a `{columnNames, data:[[…]]}` table; that
`dimension-values` accepts **no** filters; that `dimension-over-time` has its own
dimension enum plus `interval`/`limit` (1-10); that `metric-per-page-load-time`
uses a distinct single `metric`; and that the report endpoints (`geography`,
`page-groups`, `browsers`, `ab-tests`, `bandwidth`) have **no** native
`limit`/`sortby` (use the client-side `limit`).

**Silent-fallback `warning` (output side).** As a backstop for cases input
resolution can't catch, when the series id/name echoed in the response does not
match the requested `timer`/`metric`, the result carries a `warning` so the
caller knows the data is **not** for the requested name. Casing/separator-only
differences are treated as a match and never warn. The `warning` is additive
metadata and appears in `raw` mode too (data stays untouched).

**Payload instrumentation.** Every successful query logs
`payload app=… query=… bytes=… points=…` to **stderr**, for measuring response
size / token cost (e.g. before/after future history-reduction work).

**`limit` — high-cardinality caps.** `dimension-values`, `geography`,
`page-groups`, `browsers`, … can return hundreds–thousands of rows (verified
live: ~215 countries for `geography`, ~1,400 values for `dimension-values`
browser). On the `query` tool these query-types are capped at 100 by default
(pass an explicit `limit` to change it); when trimmed, the result carries
`truncated: {key, total, returned[, sorted_by]}` so nothing silently disappears.
When the rows carry a count field (e.g. geography's `timerN`), they are **sorted
by volume descending before trimming** (`sorted_by`), so the top markets survive
rather than an alphabetical slice; rows without a count (dimension-values'
strings) keep their order. `raw=true` is never trimmed.

**`empty_reason` + `probe` — why is it empty?** An unsupported drilldown
combination and genuine no-traffic both come back empty. Every empty result now
carries an `empty_reason` heuristic (`no_data` / `likely_no_traffic` /
`possibly_unsupported_combo`) at zero cost. Pass `probe=true` (on the explicit
tools or `query`) to spend **one** extra drilldown-free query and get a
definitive `unsupported_combo` vs `no_traffic`, reported in a `probe` block.

### Batch aggregate

`get_aggregate` runs a matrix of `timers-metrics` queries and returns only each
cell's period aggregate (`latest`) — the value you'd read for a monthly p75.

```jsonc
get_aggregate(
  metrics=["TotalRequestCount", "TotalTransferSize"],
  periods=[
    {"date_comparator": "Between", "date_start": "2026-06-01",
     "date_end": "2026-07-01", "label": "June"},   // date_end exclusive
    {"date_comparator": "Between", "date_start": "2026-07-01",
     "date_end": "2026-08-01", "label": "July"}
  ],
  percentiles=[50, 75]
)
// -> { "table": [ {"target":"TotalRequestCount","period":"June",
//                  "percentile":75,"value":142}, … 8 rows … ],
//      "percentiles":[50,75], "targets":[…] }
```

Period objects are `{date: "YYYY-MM-DD"}` or `{date_comparator: …}` (`Between`
needs `date_start`+`date_end`; `Last` needs `trailing_seconds`); `label` names
the column. Names are validated/auto-corrected up front (one error, not N).
A cell that fails lands in `errors[]` with its coordinates while the rest
return; `max_combos` (default 24) guards `targets × periods × percentiles`.

### Constraints surfaced to the model

One calendar day per query (ranges are rejected with guidance to split into
per-day calls — the server does not fan out implicitly); per-minute aggregation;
timezone-dependent; unsupported drilldown combinations return empty data.

## Errors

`401` → one automatic token re-issue + replay, then an auth error. `403` →
mPulse Lite / permission guidance (the Query API is not available on Lite).
`429` → exponential backoff + jitter (up to 5 retries), then a rate-limit error.
Network/timeout/5xx → retried, then a friendly upstream error. Invalid
app/query-type/params → validation errors with hints. No error message ever
contains a secret.

## Tests

```bash
uv run pytest                 # unit tests (respx-mocked; no network)
```

Covers: token mint/cache/expiry/refresh + single-flight, `Authentication`
header, 401 replay, 429 backoff, 403→Lite mapping, loss-free normalization,
`date`/`date_comparator` mutual exclusivity, and multi-app / default fallback.

**Manual smoke test** against the real API:

```bash
export MPULSE_API_TOKEN=...   # or rely on your .env / shell env
export MPULSE_APPS_CONFIG=/Users/hyoon/Projects/mpulse-mcp/mpulse_apps.json
uv run python tests/smoke.py                 # default app, yesterday
uv run python tests/smoke.py app-b 2026-08-01
```

## Project layout

```
src/mpulse_mcp/
  server.py       FastMCP instance + tool registration (entry point)
  config.py       app registry + credential loading
  auth.py         token mint/cache/refresh (single-flight)
  client.py       HTTP client + rate limiting + retries
  query_types.py  query-type / parameter metadata
  formatting.py   loss-free normalization + raw passthrough
  errors.py       typed exceptions + status mapping
tests/            unit tests + smoke.py
```

TDQS

A4.4/5.0

Scored across 8 tools

Disambiguation5/5

Each tool has a clear, distinct role: summary/histogram/time-series/metrics cover different data shapes, query is explicitly for uncovered query-types, and list_apps/list_query_types/describe_query are discovery aids. No two tools appear interchangeable.

Naming Consistency5/5

Tool names follow a predictable verb_noun pattern: get_* for data retrieval, list_* for enumeration, describe_* for details, and a single 'query' for generic access. The style is uniform and intuitive.

Tool Count5/5

Eight tools is well-scoped for an analytics server: four query-specific getters, one generic fallback, and three utility/metadata tools. Each tool earns its place without redundancy or bloat.

Completeness5/5

The server covers the core mPulse analytics surface (summary, histogram, time series) and provides a generic query tool for any undiscovered query-type, plus list/describe tools for self-discovery. No obvious gaps; agents can handle arbitrary query-types without dead ends.

Maintenance

ActivitySlowing
ResponsivenessNo issues