MCP server

Pull traffic and revenue, and create goals and funnels from any MCP client with a personal API key over /api/v1.

The XPmetric MCP server is a stdio process talking to the same /api/v1 surface as the dashboard — so an agent can pull real traffic numbers into the repo, or add window.xpmetric('event') and create the matching goal in one turn.

It works with any MCP client that can launch a local command. Claude Code, Claude Desktop, and Cursor are shown below; every other client takes the same config block. Remote HTTP MCP is not supported — there is no hosted endpoint.

A goal only fires if window.xpmetric('event_name') runs on the page (p.js must already be installed). The MCP server never writes your files — it returns a snippet; your editor tools paste it.

Mint an API key

  1. Open Settings → General → API keys.
  2. Name a key and copy it once. The prefix is xpm_mcp_. The raw key is shown only at mint time.
  3. A key covers every site on the account. Analytics tools are read-only; goal and funnel tools can create, update, and delete.

Do not use a per-site Payments secret (xpm_sk_…) here — that path is POST /api/payment only.

Revoke unused keys from the same Settings panel. Cap is 10 active keys per account.

Install

Set XPMETRIC_API_KEY to the raw key. Optional XPMETRIC_BASE_URL defaults to https://xpmetric.com (use http://127.0.0.1:3000 or http://localhost:<port> against a local app — any other host is rejected so the key is never forwarded). Never paste a real key into a copyable command — keep it in the env block.

Claude Code

{
  "mcpServers": {
    "xpmetric": {
      "command": "npx",
      "args": ["-y", "@xpmetric/mcp"],
      "env": {
        "XPMETRIC_API_KEY": "xpm_mcp_YOUR_KEY"
      }
    }
  }
}

Cursor

Add the same block under Cursor Settings → MCP (or .cursor/mcp.json):

{
  "mcpServers": {
    "xpmetric": {
      "command": "npx",
      "args": ["-y", "@xpmetric/mcp"],
      "env": {
        "XPMETRIC_API_KEY": "xpm_mcp_YOUR_KEY"
      }
    }
  }
}

Restart the MCP session after saving.

Claude Desktop

Claude Desktop reads one per-user config file, so the server is available in every chat — there is no per-project scope.

  1. Open Settings → Developer → Edit Config. That reveals claude_desktop_config.json:
    • macOS — ~/Library/Application Support/Claude/claude_desktop_config.json
    • Windows — %APPDATA%\Claude\claude_desktop_config.json
  2. Add xpmetric inside mcpServers. If the file already has servers, add it next to them and keep the commas valid — do not replace the object.
{
  "mcpServers": {
    "xpmetric": {
      "command": "npx",
      "args": ["-y", "@xpmetric/mcp"],
      "env": {
        "XPMETRIC_API_KEY": "xpm_mcp_YOUR_KEY"
      }
    }
  }
}
  1. Quit Claude Desktop completely (⌘Q on macOS) and reopen it. Closing the window does not reload the config.

xpmetric then appears in the tools menu. Ask it "how did my site do last week?" to confirm — the agent should call list_sites first.

Settings → Connectors does not work for this server. That form takes a remote HTTP URL, and XPmetric MCP is stdio only. The config file above is the supported path.

If the server does not appear after a full restart, the desktop app could not find npx. GUI apps do not inherit your shell PATH, so a Node installed through nvm, mise, or Homebrew may be invisible to it. Run which npx in a terminal and put that absolute path in command:

{
  "mcpServers": {
    "xpmetric": {
      "command": "/absolute/path/to/npx",
      "args": ["-y", "@xpmetric/mcp"],
      "env": {
        "XPMETRIC_API_KEY": "xpm_mcp_YOUR_KEY"
      }
    }
  }
}

Other MCP clients

Windsurf, Zed, Cline, Continue, and the rest read the same command / args / env shape — only the file it lives in differs. Copy the block above into whatever your client calls its MCP config, then restart it.

Local checkout

For local development, build the package in this repo and point the editor at the compiled bin:

cd packages/mcp
npm install
npm run build
{
  "mcpServers": {
    "xpmetric": {
      "command": "node",
      "args": ["/ABS/PATH/TO/xpmetric/packages/mcp/dist/index.js"],
      "env": {
        "XPMETRIC_API_KEY": "xpm_mcp_YOUR_KEY",
        "XPMETRIC_BASE_URL": "http://127.0.0.1:3000"
      }
    }
  }
}

Read tools

Ask the agent a traffic question. It uses these four tools over /api/v1.

list_sites

Which sites does this key cover? Call list_sites first. You get id, domain, and trackingMode for every site on the account — pass id into the other tools as siteId.

get_stats

How did last week look? Call get_stats with siteId and period: "7d". You get visitors, pageviews, bounce rate, average session time, and revenue when a payment provider is connected. Optional compare is previous, yoy, or custom (custom needs compareStart and compareEnd).

get_timeseries

What did each day look like? Call get_timeseries with the same siteId and period. You get visitors, pageviews, bounce, and session time per bucket. Optional granularity is hourly, daily, weekly, or monthly (coerced to what the period allows).

get_breakdown

Which pages did my German visitors read last week? Call get_breakdown with dim: "path", period: "7d", and filters:

[{ "d": "country", "o": "is", "v": ["DE"] }]

One tool covers pages, sources, countries, regions, cities, devices, browsers, goals, and the rest — pick the dimension with dim. Rows sort by the response primaryMetric (views for path-like dims, visitors otherwise).

Filters

Optional filters: JSON array [{ "d": "country", "o": "is", "v": ["DE"] }]. Values OR within a dimension, AND across dimensions. Region uses country:region (e.g. US:CA).

Same shape as the dashboard Filter menu. Pass them on any of the four read tools (or the matching /api/v1 query params).

What the numbers mean on cookieless sites

On cookieless sites (the default), newVisitors and returningVisitors on timeseries are null — daily-rotating visitor hashes would make new vs returning a salt artifact, not a person. Revenue with filters counts attributed payments only.

See GDPR, cookieless tracking & tracking modes.

Cookieless funnel rule

On cookieless sites visitor identity resets every UTC day, so visitor scope cannot complete across days. Use session scope, or windowHours ≤ 24, or ask the user to switch the site to cookie mode. Never silently rewrite scope or windowHours.

All tools

ToolPurpose
list_sitesSites on the key (id, domain, trackingMode)
get_statsPeriod totals (visitors, pageviews, bounce, session time, revenue, compare)
get_timeseriesBucketed traffic series
get_breakdownRanked breakdown for one dimension (dim = path, country, channel, …)
discover_goal_candidatesCustom events firing with no goal yet — start the write loop here
get_tracking_snippetGuarded window.xpmetric('event') snippet to paste into the repo
list_goalsGoals plus completions and CVR for a period
create_goalCreate a goal (eventName must match the track call exactly)
update_goal / delete_goalPatch or remove a goal by id
list_funnelsFunnels for a site (duplicate names allowed — use ids)
create_funnel / update_funnel / delete_funnelFunnel CRUD (2–8 steps)
get_funnel_dataStep counts and drop-off for a period

Example: track signups

  1. Call list_sites, then discover_goal_candidates for the site.
  2. If signup is not already firing, call get_tracking_snippet with eventName: "signup" and paste the snippet after a successful signup (editor tools — the MCP server does not write files).
  3. Call create_goal with the same eventName. Goals are retroactive: matching custom events already in ClickHouse still count.
if (typeof window.xpmetric === "function") {
  window.xpmetric('signup');
}

eventName is case-sensitive. Do not use xpm.track.

/api/v1 reference

Scripts and agents can call the HTTP API directly. The MCP package is a stdio wrapper over these routes.

Auth

  • Header: Authorization: Bearer xpm_mcp_…
  • A signed-in dashboard session cookie is also accepted on write routes (same billing gate as the dashboard). Analytics read routes accept the bearer key.

Rate limit

Dedicated bucket at 120 requests/minute per user — separate from the dashboard so an agent burst cannot starve an open UI.

Every response includes:

HeaderMeaning
X-RateLimit-Limit120
X-RateLimit-RemainingRemaining in the current window
X-RateLimit-ResetUnix timestamp (seconds) when the window rolls

429 body: { "error": "Too many requests" } plus Retry-After (seconds).

Errors

StatusShape
401{ "error": "Unauthorized" }
403{ "error": "Subscription required. …" } when trial/grace has ended
400{ "error": "Invalid body", "details": { "formErrors": [], "fieldErrors": { … } } } (Zod flatten())
400Cookieless × visitor scope × windowHours > 24 — same details.fieldErrors on scope and windowHours
400Unknown breakdown dim, or invalid compare args
404Unknown or other-user siteId (never 403 — keys cannot probe foreign ids)
409{ "error": "A goal for this event already exists" }(siteId, eventName) is unique

Routes

MethodPath
GET/api/v1/sites
GET/api/v1/sites/{siteId}/stats
GET/api/v1/sites/{siteId}/timeseries
GET/api/v1/sites/{siteId}/breakdown
GET / POST/api/v1/sites/{siteId}/goals
PATCH / DELETE/api/v1/sites/{siteId}/goals/{goalId}
GET/api/v1/sites/{siteId}/goals/discover
GET / POST/api/v1/sites/{siteId}/funnels
PATCH / DELETE/api/v1/sites/{siteId}/funnels/{funnelId}
GET/api/v1/sites/{siteId}/funnels/{funnelId}/data

Request bodies match the dashboard: goal name + eventName; funnel name, steps (pageview { path, match?, hostname?, label? } or event { eventName, label? }), optional windowHours and scope. On cookieless sites, omitting scope stores session.

Read query params: period / start / end / filters (same JSON as the tools), plus compare on stats, granularity on timeseries, and required dim (+ optional limit) on breakdown.