// docs / mcp

Connect an LLM to your analytics.

Abner exposes seven read tools, one site-creation tool, and one destructive site-admin tool over the Model Context Protocol. Once authorized, your agent picks the right tool for the job.

00Try it now (demo access)

No account, no signup, no OAuth flow. A public, read-only demo token connects your MCP client to Abner's own live analytics for abner.app right now. Add this to your client config:

{
  "mcpServers": {
    "abner-demo": {
      "type": "http",
      "url": "https://mcp.abner.app/mcp",
      "headers": { "Authorization": "Bearer abner_ws_demo_portfolio_readonly" }
    }
  }
}

Then just ask your agent:

"How is my traffic doing across all my sites, anything weird?"

That question routes straight to query_portfolio and get_changes, no site id needed. Demo access is read-only over one real site, abner.app's own live analytics; every write tool (create_site, delete_site) returns the tool error "demo access is read-only" instead of making a change. Here's the kind of answer you get back, one row since the demo account owns exactly one site:

// result of query_portfolio({ "period": "7d" })
{
  "period": "7d",
  "sites": [
    {
      "site_id": "…",
      "name": "Abner",
      "domain": "abner.app",
      "last_event_at": "2026-08-18T10:12:00Z",
      "status": "live",
      "metrics": {
        "visitors": { "current": 842, "previous": 761, "change_pct": 10.65 },
        "pageviews": { "current": 2103, "previous": 1988, "change_pct": 5.78 }
      }
    }
  ],
  "truncated": false
}

Ready for your own analytics? Skip to Install in Claude Code below and go through the real OAuth flow.

01Install in Claude Code

One command, browser opens for consent, you're done:

claude mcp add --transport http --scope user abner https://mcp.abner.app/mcp

Use --scope user so the server is available from any directory. The first time you call a tool, Claude Code pops your browser to log in to Abner and approve the consent screen.

In Claude Desktop

Settings → Developer → Edit Config, add:

{
  "mcpServers": {
    "abner": {
      "transport": "http",
      "url": "https://mcp.abner.app/mcp"
    }
  }
}

In Cursor

Cursor follows the same Streamable HTTP spec. Add an HTTP MCP entry pointing at https://mcp.abner.app/mcp.

02Authentication (OAuth 2.1)

Abner is a fully spec-compliant OAuth 2.1 server with:

Discovery happens via two well-known endpoints:

# issued by the resource server
GET https://mcp.abner.app/.well-known/oauth-protected-resource

# issued by the authorization server
GET https://www.abner.app/.well-known/oauth-authorization-server

Token lifetimes:

03Tools reference

Nine tools under the single scope analytics:read: seven query tools plus create_site and delete_site. Site creation and deletion are both account/site-scoped by direct ownership; deletion additionally requires exact domain confirmation.

list_sites

no args

Returns every site the authenticated user can read, each with last_event_at and a liveness status (best-effort; omitted if analytics is unavailable). Always call this first if you don't already have a site_id.

query_metrics

swiss army

Visitors, pageviews, sessions, pageviews-per-visitor — grouped by any dimension, filtered by anything, over any preset window.

query_realtime

live

Active visitors in the last five minutes plus a stream of recent events (pageviews, custom events, scroll depth).

query_vitals

p75

Core Web Vitals at the 75th percentile: LCP, FID, CLS, FCP, TTFB, INP. Takes period or a custom date_from/date_to range.

query_funnel

stateless

An ordered, per-session conversion funnel over inline steps — nothing is stored server-side; pass the steps you want each time.

query_portfolio

account-wide

Every site in the account in one call, each with metrics compared to the previous period and a liveness status. Use this before per-site tools when the question spans the whole portfolio. Example: "how are all my sites doing this week?"

get_changes

account-wide

Sites whose visitors moved at least 30 percent versus the previous period (minimum 50 visitors across both windows), ranked by swing size, each with the top pathnames and referrer hosts behind the move. Example: "anything weird going on across my sites this week?"

create_site

creates

Creates a new site under your account and returns the ready-to-paste tracking snippet. Rejects a domain that's already registered to your account; call list_sites first to check.

delete_site

destructive

Permanently deletes a site and its Postgres plus ClickHouse data. Requires direct site ownership and exact-match confirm_domain.

query_metrics — schema

type Input = {
  site_id:    "<uuid>",                    // from list_sites
  metrics:    ["visitors" | "pageviews" | "sessions" | "pageviews_per_visitor" | "events"],
  dimensions: ["pathname" | "referrer_host" | "country" | "region" | "city" |
               "browser" | "device_type" |
               "utm_source" | "utm_medium" | "utm_campaign" |
               "time_day" | "time_hour" | "event_name"],
  filters?:   { [dimension: string]: string },
  period?:    "24h" | "7d" | "14d" | "30d" | "month" | "year" | "all",    // default 7d
  date_from?: string,                       // YYYY-MM-DD (site timezone) or RFC3339; requires date_to, excludes period, span at most 366 days
  date_to?:   string,                       // inclusive for YYYY-MM-DD
  compare?:   "previous_period",           // adds each metric's {current, previous, change_pct} vs the preceding window; only valid without dimensions
  limit?:     number                        // default 50, max 1000
}

With no dimensions, returns a single overview row. Pass time_day or time_hour for a time series. Pass pathname for top pages. Pass filters: { country: "US" } to scope, or filters: { event_name: "signup" } to isolate a custom event, e.g. metrics: ["visitors"] to count signup conversions. Pass compare: "previous_period" on an overview query to get comparison: { visitors: { current, previous, change_pct }, … }; change_pct is null when the previous value was zero.

query_funnel — schema

type Input = {
  site_id:         "<uuid>",                    // from list_sites
  steps:           [string],                    // 2-8 ordered specs: "path:<pathname>" or "event:<name>"
  period?:         "24h" | "7d" | "14d" | "30d" | "month" | "year" | "all",    // default 7d
  date_from?:      string,                       // YYYY-MM-DD (site timezone) or RFC3339; requires date_to, excludes period, span at most 366 days
  date_to?:        string,                       // inclusive for YYYY-MM-DD
  window_seconds?: number                        // max seconds between first & last step in a session; default 86400, max 604800
}

Steps must occur in order within one session. Example — signup funnel over the last 30 days:

result = await session.call_tool("query_funnel", {
    "site_id": site_id,
    "period": "30d",
    "steps": ["path:/pricing", "path:/signup", "event:signed_up"],
})
// → { site_id, period: "30d", steps: [
//      { step: 1, label: "path:/pricing",   sessions: 820, conversion_rate: 100, dropoff_rate: 0 },
//      { step: 2, label: "path:/signup",    sessions: 240, conversion_rate: 29.3, dropoff_rate: 70.7 },
//      { step: 3, label: "event:signed_up", sessions: 164, conversion_rate: 20, dropoff_rate: 31.7 },
//   ] }

query_portfolio schema

type Input = {
  metrics?:   ["visitors" | "pageviews" | "sessions" | "pageviews_per_visitor" | "events"],   // default visitors, pageviews; visitors is always included
  period?:    "24h" | "7d" | "14d" | "30d" | "month" | "year" | "all",    // default 7d; always compared to the previous window of equal length
  date_from?: string,                       // YYYY-MM-DD or RFC3339; requires date_to, excludes period, span at most 366 days
  date_to?:   string                        // inclusive for YYYY-MM-DD
}

No site_id: this is the one query tool that spans every site in the account. The window resolves in UTC, since it spans sites in different timezones. Accounts with more than 500 sites are truncated after sorting by name (truncated: true). Example, "how are all my sites doing this week?":

result = await session.call_tool("query_portfolio", { "period": "7d" })
// → { period: "7d", sites: [
//      { site_id, name: "Example", domain: "example.com", last_event_at: "2026-08-18T10:12:00Z", status: "live",
//        metrics: { visitors: { current: 1240, previous: 980, change_pct: 26.53 }, pageviews: { current: 3877, previous: 4210, change_pct: -7.91 } } }
//   ], truncated: false }

get_changes schema

type Input = {
  period?:    "24h" | "7d" | "14d" | "30d" | "month" | "year" | "all",    // default 7d; always compared to the previous window of equal length
  date_from?: string,                       // YYYY-MM-DD or RFC3339; requires date_to, excludes period, span at most 366 days
  date_to?:   string                        // inclusive for YYYY-MM-DD
}

No site_id: spans every site in the account, same UTC window as query_portfolio. A site is dropped as noise when its current-plus-previous visitors total is below 50. Of the rest, a site is listed when its absolute percent change is at least 30, ranked by absolute percent change descending (a move from zero visitors ranks first), each with its top 3 contributing pathname and referrer_host values by absolute visitor delta. An empty changes list means nothing moved beyond the thresholds, which is itself the answer. Example, "anything weird going on across my sites this week?":

result = await session.call_tool("get_changes", { "period": "7d" })
// → { period: "7d", changes: [
//      { site_id, name: "Example", domain: "example.com", direction: "up",
//        visitors: { current: 1240, previous: 640, change_pct: 93.75 },
//        contributors: {
//          pathname: [ { value: "/blog/launch", current: 480, previous: 40, delta: 440 } ],
//          referrer_host: [ { value: "news.ycombinator.com", current: 390, previous: 0, delta: 390 } ] } }
//   ] }

create_site schema

type Input = {
  name:      "Marketing Site",             // human-readable, shown in the dashboard
  domain:    "example.com",                // scheme, www., and any path are stripped automatically
  timezone?: "America/New_York"            // IANA name; default UTC
}

Returns site_id (use it for every other tool), public_id (the tracking id, embedded in tracking_snippet), and the ready-to-paste tracking_snippet itself. Paste the snippet verbatim rather than assembling your own tag from public_id, since site_id and public_id are different values and only public_id is accepted by the tracking endpoint. Fails if a site with the same domain already exists in your account.

delete_site — schema

type Input = {
  site_id:        "<uuid>",                    // from list_sites
  confirm_domain: "example.com"                // must exactly match the site's domain
}

This tool permanently deletes the site row, related Postgres data, and ClickHouse rows in analytics, vitals, search-console, forecast, conversion, and anomaly tables. The authenticated user must directly own the site.

04Calling from code

The MCP server speaks plain Streamable HTTP — any MCP SDK works.

Python

from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession

async with streamablehttp_client(
    "https://mcp.abner.app/mcp",
    headers={"Authorization": f"Bearer {access_token}"},
) as (read, write, _):
    async with ClientSession(read, write) as session:
        await session.initialize()
        sites = await session.call_tool("list_sites", {})
        result = await session.call_tool("query_metrics", {
            "site_id": sites.structuredContent["sites"][0]["site_id"],
            "metrics": ["visitors", "pageviews"],
            "dimensions": ["time_day"],
            "period": "30d",
        })

TypeScript

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.abner.app/mcp"),
  { requestInit: { headers: { Authorization: `Bearer ${token}` } } },
);

const client = new Client({ name: "my-app", version: "0.1.0" });
await client.connect(transport);
const result = await client.callTool({
  name: "query_realtime",
  arguments: { site_id },
});

Curl (for debugging)

curl -sS https://mcp.abner.app/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

05Token management

Every active access token issued for your account appears at /account/mcp-tokens/. You can revoke any token there — the client will need to re-authorize.

Tokens are stored hashed at rest (SHA-256). Plaintext is returned exactly once on issuance and never logged.

06Scopes & rate limits

v0.1 has a single scope, analytics:read. Query tools are read-only. delete_site is destructive and is additionally guarded by direct site ownership plus exact domain confirmation.

Rate limits will be applied per-token; documented values will land here once they're enforced. For now: be reasonable.