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
- Open Settings → General → API keys.
- Name a key and copy it once. The prefix is
xpm_mcp_. The raw key is shown only at mint time. - 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.
- 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
- macOS —
- Add
xpmetricinsidemcpServers. 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"
}
}
}
}- 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
| Tool | Purpose |
|---|---|
list_sites | Sites on the key (id, domain, trackingMode) |
get_stats | Period totals (visitors, pageviews, bounce, session time, revenue, compare) |
get_timeseries | Bucketed traffic series |
get_breakdown | Ranked breakdown for one dimension (dim = path, country, channel, …) |
discover_goal_candidates | Custom events firing with no goal yet — start the write loop here |
get_tracking_snippet | Guarded window.xpmetric('event') snippet to paste into the repo |
list_goals | Goals plus completions and CVR for a period |
create_goal | Create a goal (eventName must match the track call exactly) |
update_goal / delete_goal | Patch or remove a goal by id |
list_funnels | Funnels for a site (duplicate names allowed — use ids) |
create_funnel / update_funnel / delete_funnel | Funnel CRUD (2–8 steps) |
get_funnel_data | Step counts and drop-off for a period |
Example: track signups
- Call
list_sites, thendiscover_goal_candidatesfor the site. - If
signupis not already firing, callget_tracking_snippetwitheventName: "signup"and paste the snippet after a successful signup (editor tools — the MCP server does not write files). - Call
create_goalwith the sameeventName. 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | 120 |
X-RateLimit-Remaining | Remaining in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window rolls |
429 body: { "error": "Too many requests" } plus Retry-After (seconds).
Errors
| Status | Shape |
|---|---|
| 401 | { "error": "Unauthorized" } |
| 403 | { "error": "Subscription required. …" } when trial/grace has ended |
| 400 | { "error": "Invalid body", "details": { "formErrors": [], "fieldErrors": { … } } } (Zod flatten()) |
| 400 | Cookieless × visitor scope × windowHours > 24 — same details.fieldErrors on scope and windowHours |
| 400 | Unknown breakdown dim, or invalid compare args |
| 404 | Unknown 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
| Method | Path |
|---|---|
| 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.
Related
- Custom goals — the dashboard path for the same events
- Conversion funnels — URL matching and visitor vs session
- GDPR, cookieless tracking & tracking modes — what null new/returning means
- Get started — install
p.jsfirst