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_experiment_landings | Private exact first-landing evidence for a server milestone producer (repository MCP build) |
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 | /api/v1/sites/{siteId}/experiments/{experimentId}/landings |
| 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
Acquisition, crawler reads and experiment markers
get_funnel_data and list_goals accept the same validated filters array as analytics: [{"d":"channel","v":["Organic Search"]}], AI via channel=AI, or each owned campaign via utm_medium=owned_referral plus utm_campaign. Acquisition filters select sessions by entry attribution; downstream events do not need UTMs. Cookieless visitor identity resets daily; use session funnels or cookie tracking for genuine cross-day identity.
get_bot_traffic reads site-scoped server crawler hits with provider, category (all, answer_fetch, search_index, training), path (sanitized wildcard path), status (e.g. 404), and period/start/end. Rows include UTC firstSeen/lastSeen, status and counts. Labels classify agents and do not verify crawler identity from user agent alone. Errors mean unavailable, never zero hits; results cap at 200 grouped rows.
create_chart_marker, list_chart_markers, and update_chart_marker reuse dashboard chart annotations. Creation requires a stable public externalId such as deploy:experiment-42: all retries return the original marker once, even if retry payload changes. Correct it by marker id with update. date is the UTC chart day; optional occurredAt is the exact caller-supplied event timestamp (null means unavailable). source distinguishes API from dashboard creation; createdAt is recording time. Keep labels public, and URLs free of credentials, queries and fragments.
A growth agent can read Organic Search, AI and each owned referral campaign funnel, inspect crawler 404s, then create a deployment marker with the deployment's stable external experiment id. Repeating the create call is safe and the annotation appears in the existing chart UI.
For synthetic QA, POST /api/event accepts optional synthetic: true. Either tracker supports data-synthetic="true" on its script to mark every pageview and custom QA event. Marked events return 204 before allowance, XP or analytics storage; ordinary payloads keep their existing behavior.
Search and AI evidence
get_growth_evidence reads or exports source observations for an owned site. Filter
by provider, inclusive measured start/end, canonical public page, or
experiment. report: true adds separately queried live human page traffic and
active goal events for the same UTC dates. Each component includes replayable
filters; per-page unique visitors must never be summed into a site total.
Import aggregate evidence with POST /api/v1/sites/{siteId}/growth-evidence:
{ collection, rows }. Collection identity is (site, provider, kind, externalId);
row identity is (collection, externalId). Retries replace that collection's
snapshot atomically and preserve IDs. Requests are limited to 1,000 rows; split
large exports into distinct stable collection keys and label partial coverage.
API keys and dashboard sessions share ownership, billing and rate checks.
Search rows contain clicks/impressions and optional position; CTR is derived as clicks divided by impressions (unavailable without a denominator). Crawl rows contain index status and optional crawl date/HTTP status. Fixed prompt rows use a public prompt ID and model/version, never private user prompts or answer logs. Bing AI rows contain citation totals from a local authenticated export; these are visibility, never estimated visits. Google Web search already includes AI Overviews and AI Mode and cannot be separated into an isolated Google AI metric.
Collections expose source provider, method, surface, locale, covered period, timezone, retrieval time, completeness, lag and evidence URL. Dates retain each source's timezone; the human component explicitly uses UTC. Missing source access, expired authentication, unsupported metrics and partial exports are unavailable, stale or partial, never invented zeros. Observations older than seven days are marked stale. Reads return at most 100 collections and 1,000 rows per collection and explicitly flag truncation. Different provider grains stay in separate collections; weighted position only makes sense within one compatible grain.
The site dashboard's Growth evidence link shows these observations beside separately measured human page traffic/conversions. No observation creates browser visits, goal events, XP, or conversion attribution. Source credentials have not been supplied in the development acceptance run: actual Google/Bing access is pending, and local fixture readback is not proof of live provider access.
Acquisition experiments
upsert_growth_experiment saves a stable, owned externalId; retries update the
same record. list_growth_experiments lists definitions, and
get_growth_experiment returns a report for a 30, 60 or 90 day review. The site
Acquisition experiments view uses the same API/report. Every experiment stores
its cohort definitions, including Organic Search, AI, owned referrals by
source/campaign, Direct/unknown and existing customers. An optional annotationId
links an owned chart marker. Public hypotheses, metrics, rollout/control,
launch/change times, observation/payment windows, spend/time, evidence URLs and
decision/rollback references are metadata; ACO references accept roadmap/task keys
only. Optional excludedCampaigns keeps operator/QA campaigns out of the cohort;
XPmetric selects each browser's first eligible landing before applying this list,
so an excluded first campaign remains excluded on later visits. No private
task/user text or credentials belong in these fields.
The public endpoints are GET/POST /api/v1/sites/{siteId}/experiments and
GET /api/v1/sites/{siteId}/experiments/{experimentId}. Reads accept reviewDays
and optional historical asOf; future dates are rejected. List reads accept
externalId. Upserts preserve the experiment ID and enforce site ownership for
both public landing URLs and linked markers.
A connected application must send server-confirmed milestones through
POST /api/v1/sites/{siteId}/experiments/{experimentId}/outcomes. This contract is
currently pending/unconnected; browser goals cannot establish account
creation or first task completion. A snapshot supplies externalId,
periodStart, periodEnd, observedThrough, retrievedAt, explicit coverage,
notes, aggregate unknownCounts: { noConsent, crossDevice } and records.
Each record has a site-salted 64-character hexadecimal pseudonymous externalId,
consented existing XPmetric visitorHash, landingAt, optional
accountCreatedAt/firstTaskAt, returningAccount, and the required assertions
consent: true, sameBrowser: true, serverConfirmed: true. Do not send email,
raw account/task IDs, task text, private prompts or answer logs. No-consent and
cross-device unknowns use aggregate counts only, never identifiable records.
Landing/source eligibility is verified against measured site browser events; imported records cannot invent source attribution. A newly created account and server-confirmed first task must occur within seven days of the eligible landing. Returning accounts remain separate. Cookie mode and same-browser consent are required for cross-day linkage; cross-device linkage is unavailable and cookieless records cannot establish this seven-day outcome. Reports keep leading source evidence, measured arrivals, activation and matured paid outcomes separate. Costs per activation are null when activation is zero or unavailable. Outcome completeness requires the whole observation window plus maturation, not merely a calendar review date. Missing, stale, partial or failed milestone ingestion never becomes a zero activation rate.
Paid outcomes reuse existing site PaymentEvent identity and billing windows;
there is no new payment/customer identity and no estimated revenue from AI
citations. Currency totals remain separate and known refunds reduce original
payments once. Existing-customer renewal behavior is distinct from new acquisition.
Reports expose sample sizes, unknowns, coverage, freshness and maturation so a
review cannot mistake an incomplete sample for an experiment result.
Private server milestone evidence
A server-side account or task milestone must reference XPmetric's measured arrival time. Read it through GET /api/v1/sites/{siteId}/experiments/{experimentId}/landings, using the personal API key above, or get_experiment_landings from the local repository build. The published npm package may not yet expose this tool. The caller must own the site and experiment, and the site must use cookie tracking.
Pass UTC ISO periodStart and periodEnd within experiment launch through launch + 90 days. Optional observedThrough defaults to now and cannot be in the future. The covered period may end in the future. Omit format to preserve the private xpmetric-measured-landings/v1 JSON, or request format=v2 for the same window/provenance/first-landing rows plus a coverageProof. Keep hashes private and use the returned time directly in the server producer. An application capture time or browser goal cannot substitute for it.
Selection starts at experiment launch and takes each browser's earliest eligible session entry before applying excludedCampaigns and filtering the requested inclusive period. A browser whose first campaign is excluded stays excluded on later visits, and its outcome rows do not enter observed totals. A later visit cannot replace an earlier arrival. Query failure, incompatible data or more than 100,000 measured session entries returns 503 without evidence. Responses are private and never cached.
The observation cutoff is not an ingestion-completeness watermark. format defaults to v1; request format=v2 to add coverageProof, a SHA-256 fingerprint of the current stored eligible measured population, including nonconverters. For coverage: "complete", copy it unchanged to outcome landingProof, close the landing period (periodEnd <= observedThrough) and report zero no-consent/cross-device unknowns. Reports revalidate the proof against the same full source read used for arrivals, extending the read through the proof period when needed. Late stored rows at or before the cutoff, erasure, changed eligibility, cap or read failure downgrade complete coverage. The proof does not establish total browser delivery, consent capture, or outcomes outside the measured population; unknowns stay unknown. Consent withdrawal, server-confirmed milestones, account linking and the independent QA capture census remain the producer's responsibility.