From c1b45f43868c514e15c4df3bbd3a88471890a976 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 18 Sep 2026 10:44:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?Convert=20to=20MCP=20server.=20Track=20even?= =?UTF-8?q?ts,=20pageviews,=20users=20and=20metrics=20through=20tools=20ca?= =?UTF-8?q?llable=20from=20Claude,=20Cursor,=20VS=20Code=20=E2=80=94=20cre?= =?UTF-8?q?ate=20an=20account,=20record=20events,=20or=20read=20reports=20?= =?UTF-8?q?with=20your=20API=20key.=20Streamable=20HTTP=20at=20/mcp=20with?= =?UTF-8?q?=2027=20tools=20covering=20analytics=20overview,=20traffic,=20p?= =?UTF-8?q?ages,=20events,=20metrics,=20users,=20live=20visitors,=20page?= =?UTF-8?q?=20flow,=20and=20crawlers.=20Share=20ingest=20logic=20(Ping=20m?= =?UTF-8?q?odule),=20client=20IP=20detection,=20and=20account=20provisioni?= =?UTF-8?q?ng=20between=20REST=20and=20MCP=20endpoints=20so=20events=20mea?= =?UTF-8?q?n=20exactly=20the=20same=20thing=20either=20way.=20580/581=20te?= =?UTF-8?q?sts=20passing;=20one=20flaky=20hash=20test=20on=20OTP=2028=20CI?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared ping ingest module (WebAnalytics.Ingest.Ping) used by both /api/ping and MCP track_event tool, so rules never drift between endpoints. - Extract ClientIP helper module for unified x-forwarded-for handling with trust_proxy_headers config in collect, account creation, crawler reporting. - Extract AccountProvisioning module shared between REST endpoint and MCP create_analytics_account tool, same rate limiting and error handling. - Add API keys table, schema and context; authenticate MCP clients with Bearer tokens; max 20 per account, rate-limited to prevent abuse. - Build MCP server module with stateless (2026-07-28) and handshake (2024-11-05 to 2025-11-25) protocol support, server/discover, tool dispatching, resource listing, prompt rendering, and proper error codes. - Implement 27 MCP tools: create_analytics_account and track_event (writing, no key); get_integration_guide; 16 reading tools for account, overview, pages, traffic sources, events, metrics, users, live visitors, flow, crawlers; plus resources (llms.txt) and prompts (add_analytics, analytics_report). - Add /mcp HTTP endpoint with Streamable HTTP transport, CORS for cross-origin, authentication via Authorization: Bearer header, support both JSON requests and JSON-RPC batches. - Add MCP landing page explaining the server, showing copy-paste snippets for Claude Code, Cursor, VS Code, and an overview of all 27 tools in a sortable table. - Add API key creation/revocation UI to Getting Started; keys shown once and stored only as SHA-256 hash; last_used_at updated max once per minute to avoid turning every read into a write. - Update Cors plug to accept configurable methods and headers for MCP's larger header set (mcp-protocol-version, mcp-method, mcp-name, authorization, etc.). - Move landing controller's llms.txt text generation to public method so MCP's resources/read can serve the same document, with deployment-specific URLs filled in both places. - Leverage existing Analytics API (overview, timeseries, pages, users, metrics, etc.) to answer every reading tool; nothing duplicated, same reports as dashboard. Pages affected: - [Analytics MCP Server](https://seriouslysimpleanalytics.com/analytics-mcp-server) -- Connect Claude or ChatGPT to your analytics, create accounts and track events as a tool. - [Getting Started](https://seriouslysimpleanalytics.com/getting-started) -- Create API keys for MCP clients, manage revocation, see integration snippets for website and AI tools. - [Integration Guide](https://seriouslysimpleanalytics.com/llms.txt) -- Full event API contract, now served as MCP resource; unchanged content. --- lib/web_analytics/api_keys.ex | 117 +++ lib/web_analytics/api_keys/api_key.ex | 16 + lib/web_analytics/ingest/ping.ex | 299 ++++++ lib/web_analytics_web/account_provisioning.ex | 146 +++ lib/web_analytics_web/client_ip.ex | 28 + .../controllers/account_controller.ex | 160 +-- .../controllers/collect_controller.ex | 21 +- .../controllers/landing_controller.ex | 18 +- .../controllers/landing_html.ex | 44 +- .../controllers/landing_html/mcp.html.heex | 156 +++ .../controllers/mcp_controller.ex | 135 +++ .../controllers/ping_controller.ex | 294 +----- .../live/getting_started_live.ex | 43 +- .../live/getting_started_live.html.heex | 113 +++ lib/web_analytics_web/mcp/catalog.ex | 140 +++ lib/web_analytics_web/mcp/server.ex | 329 +++++++ lib/web_analytics_web/mcp/tools.ex | 923 ++++++++++++++++++ lib/web_analytics_web/plugs/cors.ex | 18 +- lib/web_analytics_web/plugs/crawler_report.ex | 14 +- lib/web_analytics_web/router.ex | 26 + .../20260914120000_create_api_keys.exs | 23 + .../controllers/mcp_controller_test.exs | 443 +++++++++ 22 files changed, 3037 insertions(+), 469 deletions(-) create mode 100644 lib/web_analytics/api_keys.ex create mode 100644 lib/web_analytics/api_keys/api_key.ex create mode 100644 lib/web_analytics/ingest/ping.ex create mode 100644 lib/web_analytics_web/account_provisioning.ex create mode 100644 lib/web_analytics_web/client_ip.ex create mode 100644 lib/web_analytics_web/controllers/landing_html/mcp.html.heex create mode 100644 lib/web_analytics_web/controllers/mcp_controller.ex create mode 100644 lib/web_analytics_web/mcp/catalog.ex create mode 100644 lib/web_analytics_web/mcp/server.ex create mode 100644 lib/web_analytics_web/mcp/tools.ex create mode 100644 priv/repo/migrations/20260914120000_create_api_keys.exs create mode 100644 test/web_analytics_web/controllers/mcp_controller_test.exs diff --git a/lib/web_analytics/api_keys.ex b/lib/web_analytics/api_keys.ex new file mode 100644 index 0000000..d80c93e --- /dev/null +++ b/lib/web_analytics/api_keys.ex @@ -0,0 +1,117 @@ +defmodule WebAnalytics.ApiKeys do + @moduledoc """ + Read access to an account's reports, for programs. + + The account ID is public — it sits in the page source of every tracked site — + so it can only ever be allowed to write. Reading a report back needs a + secret, and this is it: an MCP client sends one as a bearer token. + + Keys are random, shown once, and stored only as a SHA-256 hash. A hash rather + than bcrypt because the key is 256 random bits, not a password: nothing about + it can be guessed, so a slow hash would only slow down every request that + presents one. + """ + import Ecto.Query + + alias WebAnalytics.ApiKeys.ApiKey + alias WebAnalytics.Repo + alias WebAnalytics.Sites.Site + + @prefix "ssa_" + @max_active 20 + # Writing last_used_at on every request would turn each read into a write. + # Once a minute is plenty to answer "is this key still in use?". + @touch_after_seconds 60 + + @doc "The literal every key starts with, so a leaked one is recognisable." + def prefix, do: @prefix + + @doc """ + Mints a key for `site`. Returns `{:ok, token, api_key}` — the token is the + only copy there will ever be. + """ + def create(%Site{} = site, name \\ nil) do + if count_active(site) >= @max_active do + {:error, :too_many} + else + token = @prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + + %ApiKey{} + |> Ecto.Changeset.change(%{ + site_id: site.id, + name: name |> to_string() |> String.trim() |> String.slice(0, 80) |> default_name(), + prefix: String.slice(token, 0, 12), + token_hash: hash(token) + }) + |> Repo.insert() + |> case do + {:ok, key} -> {:ok, token, key} + {:error, changeset} -> {:error, changeset} + end + end + end + + defp default_name(""), do: "API key" + defp default_name(name), do: name + + @doc """ + The site a presented token unlocks, or `:error` for anything that is not a + live key — unknown, revoked or malformed alike, so a caller cannot tell which. + """ + def authenticate(@prefix <> _ = token) do + query = + from k in ApiKey, + join: s in assoc(k, :site), + where: k.token_hash == ^hash(token) and is_nil(k.revoked_at), + select: {k, s} + + case Repo.one(query) do + nil -> + :error + + {key, site} -> + touch(key) + {:ok, site} + end + end + + def authenticate(_token), do: :error + + @doc "Keys for a site, newest first, revoked ones included." + def list(%Site{id: site_id}) do + Repo.all(from k in ApiKey, where: k.site_id == ^site_id, order_by: [desc: k.id]) + end + + @doc "Revokes one of `site`'s keys. A key belonging to another site is not found." + def revoke(%Site{id: site_id}, id) do + case Repo.get_by(ApiKey, id: id, site_id: site_id) do + nil -> + {:error, :not_found} + + %ApiKey{revoked_at: nil} = key -> + key |> Ecto.Changeset.change(revoked_at: DateTime.utc_now()) |> Repo.update() + + key -> + {:ok, key} + end + end + + defp count_active(%Site{id: site_id}) do + Repo.aggregate( + from(k in ApiKey, where: k.site_id == ^site_id and is_nil(k.revoked_at)), + :count + ) + end + + defp touch(%ApiKey{last_used_at: last} = key) do + now = DateTime.utc_now() + + if is_nil(last) or DateTime.diff(now, last) >= @touch_after_seconds do + Repo.update_all(from(k in ApiKey, where: k.id == ^key.id), set: [last_used_at: now]) + end + + :ok + end + + defp hash(token), do: :crypto.hash(:sha256, token) +end diff --git a/lib/web_analytics/api_keys/api_key.ex b/lib/web_analytics/api_keys/api_key.ex new file mode 100644 index 0000000..f2555ad --- /dev/null +++ b/lib/web_analytics/api_keys/api_key.ex @@ -0,0 +1,16 @@ +defmodule WebAnalytics.ApiKeys.ApiKey do + @moduledoc "A secret that lets a program read one account's reports." + use Ecto.Schema + + schema "api_keys" do + belongs_to :site, WebAnalytics.Sites.Site + + field :name, :string + field :prefix, :string + field :token_hash, :binary, redact: true + field :last_used_at, :utc_datetime_usec + field :revoked_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end +end diff --git a/lib/web_analytics/ingest/ping.ex b/lib/web_analytics/ingest/ping.ex new file mode 100644 index 0000000..c32e831 --- /dev/null +++ b/lib/web_analytics/ingest/ping.ex @@ -0,0 +1,299 @@ +defmodule WebAnalytics.Ingest.Ping do + @moduledoc """ + Turns the one-URL event API's parameters into an ingest batch. + + Shared by the HTTP endpoint (`GET /api/ping`) and the MCP server's + `track_event` tool, so an event means exactly the same thing whichever way it + arrived: the same aliases, the same reserved names, the same automatic + sessions. Two copies of these rules would drift, and an agent switching from + one to the other would see its reports change shape for no visible reason. + """ + + alias WebAnalytics.Geo + alias WebAnalytics.Geo.Countries + alias WebAnalytics.Ingest + alias WebAnalytics.Sites + + # Names that mean "someone looked at something", which are recorded as real + # pageviews so they land in the pages and flow reports rather than only in the + # event list. + @pageview_events ~w(page_view pageview pv view screen screen_view) + + @reserved ~w(uid id site u type channel project app event name sid session path page + title ref referrer visitor v format bot agent ai tz timezone + email contact user user_id userid + c city cc county s_p state province region n nation country) + + # How long consecutive pings from the same caller are treated as one session + # when no session id is supplied. Thirty minutes is the convention analytics + # has used for inactivity windows for decades. + @session_window_seconds 1_800 + + @doc "Parameter names with a fixed meaning, which are never kept as attributes." + def reserved, do: @reserved + + @doc """ + Records one ping. + + `params` uses the API's own names — string keys, exactly as they would arrive + in a query string or JSON body. Options: `:ip` (the caller's address, used + only to hash and mask), `:headers` (for the location fallback) and + `:received_at`. + + Returns `{:ok, site}`, or `:unknown_account`. The HTTP endpoint deliberately + answers both the same way; the distinction is for callers that already prove + they own the account. + """ + def submit(params, opts \\ []) when is_map(params) do + received_at = Keyword.get(opts, :received_at, DateTime.utc_now()) + ip = Keyword.get(opts, :ip) + headers = Keyword.get(opts, :headers, []) + + case Sites.fetch_site_by_key(account_id(params)) do + nil -> + :unknown_account + + site -> + ip_hash = Ingest.hash_ip(ip, site) + + Ingest.submit(site, payload(params, received_at, session_token(params, site, ip_hash)), + received_at: received_at, + user_id: user_id(params), + user_traits: user_traits(params), + ip_hash: ip_hash, + ip_masked: Ingest.mask_ip(ip), + location: location(params, headers, ip), + project: param(params, ~w(project app)), + channel: param(params, ~w(type channel)) || "ai", + agent_name: param(params, ~w(name agent ai)), + contact_email: param(params, ~w(email contact)) + ) + + {:ok, site} + end + end + + # -- user ---------------------------------------------------------------- + + # The caller's own ID for who this ping is about: an account, a customer, a + # mailbox. Accepted as a number as well as a string, because a JSON body + # carries ids as numbers far more often than not, and param/2 — built for + # query strings — would drop `"user": 42` without a word. + # + # `visitor` is the older name for the same idea and still counts, so a tool + # already sending it shows up by user without changing anything. + defp user_id(params) do + Enum.find_value(~w(user user_id userid visitor), fn key -> + case Map.get(params, key) do + value when is_integer(value) -> Integer.to_string(value) + value when is_binary(value) -> blank_to_nil(value) + _ -> nil + end + end) + end + + # Every other identifier, by prefix: user_domain, user_address, user_plan. + # One convention rather than a fixed list, because what identifies a user is + # the caller's business — an email provider has an account, a domain and an + # address; a marketplace has a seller and a wallet. + # + # They stay on the event as ordinary attributes too. This copy is what lets + # the dashboard show them beside the user without reading every event. + defp user_traits(params) do + params + |> Enum.filter(fn {key, value} -> + is_binary(key) and String.starts_with?(key, "user_") and key not in @reserved and + (is_binary(value) or is_number(value) or is_boolean(value)) + end) + |> Map.new(fn {key, value} -> + {String.replace_prefix(key, "user_", ""), to_string(value)} + end) + end + + defp blank_to_nil(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + # -- location ------------------------------------------------------------ + + # A caller's own location parameters always win over anything this server + # could work out. + # + # That is not a preference, it is the only correct answer for this endpoint: + # a ping arrives from wherever the tool runs — a laptop, a container, a + # serverless region three countries away — so its source address says where + # the *software* is, not where its user is. Resolving the address would + # produce a confident, wrong answer. Falling back to it at all is only + # reasonable for the browser tracker, where the connection really is the + # visitor's. + defp location(params, headers, ip) do + supplied = %{ + city: param(params, ~w(c city)), + county: param(params, ~w(cc county)), + region: param(params, ~w(s_p state province region)), + country: param(params, ~w(n nation country)) + } + + if Enum.any?(Map.values(supplied), &is_binary/1) do + code = Countries.code_for(supplied.country) + + %{ + Geo.empty() + | city: supplied.city, + county: supplied.county, + region: supplied.region, + country: Countries.name(code) || supplied.country, + country_code: code, + source: "client" + } + else + Ingest.locate(headers, ip) + end + end + + # -- payload ------------------------------------------------------------- + + defp payload(params, received_at, token) do + # `name` is deliberately NOT an alias for `event` any more. It now identifies + # the AI tool doing the reporting, and one parameter cannot mean two things: + # a caller sending name=Claude would otherwise have silently renamed its + # event instead of identifying itself. + event_name = param(params, ~w(event)) || "ping" + path = param(params, ~w(path page)) + now = DateTime.to_unix(received_at, :millisecond) + + %{ + "k" => account_id(params), + "s" => token, + # A user id is the best visitor identity a ping can have: it is what makes + # "visitors" count people rather than sessions for a tool that says who + # it is acting for. + "v" => param(params, ~w(visitor v)) || user_id(params), + "t" => now, + "e" => [init_event(params, now) | [body_event(event_name, path, params, now)]] + } + end + + # The caller's user agent is deliberately *not* forwarded for classification. + # A ping is a tool reporting its own usage, and most such callers are a script + # or an HTTP library — classifying them by user agent would file every one as + # a crawler and quietly filter the owner's own telemetry out of their own + # reports. Only an explicit `bot=` marks a ping as automated. + defp init_event(params, now) do + %{ + "n" => "init", + "t" => now, + "ref" => param(params, ~w(ref referrer)), + "bot" => param(params, ~w(bot agent)), + "tz" => param(params, ~w(tz timezone)), + "hb" => 10_000 + } + end + + # `event=page_view` with a path is a real pageview; everything else is a + # named event. That mapping is documented, so a caller gets pages and flow + # reporting by naming the event the obvious thing rather than by learning a + # second parameter. + defp body_event(event_name, path, params, now) do + if String.downcase(event_name) in @pageview_events and path do + # No `seq` and no `from`: the server continues the session's sequence and + # links this page to the one before it, which is what builds the flow + # graph without the caller having to track any of it. + %{ + "n" => "pv", + "t" => now, + "path" => path, + "title" => param(params, ~w(title)), + "ref" => param(params, ~w(ref referrer)) + } + else + # No `pv` either: the event attaches to whatever page the session is on. + %{ + "n" => "event", + "t" => now, + "name" => event_name, + "text" => param(params, ~w(title)), + "data" => extras(params) + } + end + end + + # Anything the caller invented, kept as attributes. + defp extras(params) do + params + |> Enum.reject(fn {key, value} -> key in @reserved or is_nil(value) end) + |> Enum.take(20) + |> Map.new(fn {key, value} -> {to_string(key), attribute_value(value)} end) + end + + # A query string only ever carries strings, but a JSON body carries whatever + # the caller put in it. to_string/1 has no clause for a map or a list, so + # "meta": {"repo": "x"} raised — and the whole ping was lost with a 500, not + # just the field that could not be stored. An agent posting structured + # context would have been silently dropping every event it sent. + # + # Nested values are kept as their JSON rather than thrown away, capped so one + # large blob cannot bloat a row. They never become metrics — a key is a + # metric only when its values are plain numbers — which is correct: there is + # nothing to sum in an object. + defp attribute_value(value) when is_binary(value), do: value + defp attribute_value(value) when is_number(value) or is_boolean(value), do: to_string(value) + + defp attribute_value(value) when is_map(value) or is_list(value) do + value |> Jason.encode!() |> String.slice(0, 1_000) + end + + defp attribute_value(value), do: inspect(value) + + # A session id from the caller is authoritative. Without one, pings are grouped + # by who and what they came from within a rolling half-hour, so that a tool + # that never passes `sid` still produces sessions — and therefore page flow — + # rather than a pile of one-event sessions that no report can connect. + # + # The window tumbles rather than sliding, so a long run can straddle a boundary + # and split in two. That is the price of deriving a session without a lookup on + # every ping; `sid` is there for callers that need exactness. + defp session_token(params, site, ip_hash) do + case param(params, ~w(sid session)) do + nil -> derived_token(params, site, ip_hash) + explicit -> explicit + end + end + + # A user id comes first. A backend reporting for many users sends every ping + # from one address, and grouping by address would fold all of them into a + # single session — one "user" doing everything at once. + defp derived_token(params, site, ip_hash) do + project = param(params, ~w(project app)) || "-" + who = user_id(params) || param(params, ~w(visitor v)) || ip_hash || "anon" + window = div(System.system_time(:second), @session_window_seconds) + + digest = + :sha256 + |> :crypto.hash([site.key, "|", project, "|", who, "|", Integer.to_string(window)]) + |> Base.url_encode64(padding: false) + |> binary_part(0, 22) + + "auto-" <> digest + end + + defp account_id(params), do: param(params, ~w(uid id site u)) + + defp param(params, keys) do + Enum.find_value(keys, fn key -> + case Map.get(params, key) do + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end + end) + end +end diff --git a/lib/web_analytics_web/account_provisioning.ex b/lib/web_analytics_web/account_provisioning.ex new file mode 100644 index 0000000..d601c68 --- /dev/null +++ b/lib/web_analytics_web/account_provisioning.ex @@ -0,0 +1,146 @@ +defmodule WebAnalyticsWeb.AccountProvisioning do + @moduledoc """ + Self-service account creation, for agents. + + Shared by `POST /api/v1/accounts` and the MCP server's + `create_analytics_account` tool. An AI tool can call either, get an account + id back, and start reporting events in the same run. Requiring a human to + stop and fill in a signup form first is the thing most likely to end an + integration before it starts, so there is no form and no key exchange. + + Two shapes, depending on whether a human is reachable: + + * with `email` — the magic link is mailed to that address and never + returned, so the address still has to be controlled by whoever ends up + signing in; + * without `email` — the account is anonymous and the result carries a + one-time claim link, which is then the only way into it. + """ + use WebAnalyticsWeb, :verified_routes + + alias WebAnalytics.Accounts + alias WebAnalytics.RateLimiter + alias WebAnalytics.Sites + + # Generous enough that a developer retrying by hand never notices, low enough + # that a script cannot fill the users table. + @limit 5 + @window_ms 3_600_000 + + @doc """ + Creates an account for `params` (`project` or `name`, and optionally `email` + or `contact`), rate limited per client address. + + Returns `{:ok, body, site}`, or `{:error, reason, body}` where reason is + `:email_taken`, `:invalid` or `{:rate_limited, retry_after_seconds}`. The + bodies are what the HTTP endpoint returns, so both callers describe an + outcome in the same words. + """ + def create(params, client_ip) do + case RateLimiter.hit({:account_create, client_ip}, @limit, @window_ms) do + :ok -> provision(params) + {:error, retry_after} -> {:error, {:rate_limited, retry_after}, rate_limited(retry_after)} + end + end + + defp provision(params) do + email = normalize_email(params["email"] || params["contact"]) + + if email && Accounts.get_user_by_email(email) do + {:error, :email_taken, + %{ + error: "email_taken", + message: "That email already has an account. Sign in to see its account id.", + login_url: url(~p"/users/log-in") + }} + else + do_provision(email, Sites.generate_key(), params) + end + end + + defp do_provision(email, key, params) do + # A reserved TLD, so an anonymous account can satisfy the unique-email + # constraint without ever addressing mail at a real person. + login_email = email || "#{key}@unclaimed.invalid" + site_attrs = %{"key" => key, "name" => site_name(params)} + + case Accounts.provision_account(%{email: login_email}, site_attrs) do + {:ok, user, site, token} -> + {:ok, created(user, site, token, email), site} + + {:error, changeset} -> + {:error, :invalid, + %{error: "invalid", message: "Could not create an account.", details: errors(changeset)}} + end + end + + defp created(user, site, token, email) do + body = %{ + uid: site.key, + account_id: site.key, + project: site.name, + dashboard_url: url(~p"/dashboard"), + docs_url: url(~p"/llms.txt"), + ping_url: ping_url(site) + } + + if email do + Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}")) + + Map.merge(body, %{ + claim: "emailed", + message: "Account created. A sign-in link was emailed to #{email}." + }) + else + Map.merge(body, %{ + claim: "link", + claim_url: url(~p"/users/log-in/#{token}"), + message: + "Account created. Give claim_url to a human to open the dashboard — " <> + "it is the only way in, so store it or set an email in settings." + }) + end + end + + defp ping_url(site) do + url(~p"/api/ping?#{[uid: site.key, type: "ai", project: site.name, event: "page_view"]}") + end + + defp rate_limited(retry_after) do + %{ + error: "rate_limited", + message: "Too many accounts created from this address. Try again later.", + retry_after: retry_after + } + end + + defp site_name(params) do + case params["project"] || params["name"] do + value when is_binary(value) -> + case value |> String.trim() |> String.slice(0, 80) do + "" -> "My project" + trimmed -> trimmed + end + + _ -> + "My project" + end + end + + defp normalize_email(value) when is_binary(value) do + case value |> String.trim() |> String.downcase() do + "" -> nil + email -> email + end + end + + defp normalize_email(_), do: nil + + defp errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), "") |> to_string() + end) + end) + end +end diff --git a/lib/web_analytics_web/client_ip.ex b/lib/web_analytics_web/client_ip.ex new file mode 100644 index 0000000..584a121 --- /dev/null +++ b/lib/web_analytics_web/client_ip.ex @@ -0,0 +1,28 @@ +defmodule WebAnalyticsWeb.ClientIP do + @moduledoc """ + The address a request came from. + + `x-forwarded-for` is client-controlled and only trusted when the deployment + says it sits behind a proxy that overwrites it (`SSA_TRUST_PROXY`). Behind + one, the socket address is the proxy's own — every visitor would share it — + so ignoring the header there is as wrong as trusting it anywhere else. + + The address is never stored raw: callers salt and hash it, or mask it. + """ + import Plug.Conn + + @doc "The client address as a string, or nil if the connection has none." + def get(conn) do + if Application.get_env(:web_analytics, :trust_proxy_headers, false) do + case get_req_header(conn, "x-forwarded-for") do + [value | _] -> value |> String.split(",") |> List.first() |> String.trim() + [] -> remote_ip(conn) + end + else + remote_ip(conn) + end + end + + defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil + defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string() +end diff --git a/lib/web_analytics_web/controllers/account_controller.ex b/lib/web_analytics_web/controllers/account_controller.ex index d9f4f4f..799cc92 100644 --- a/lib/web_analytics_web/controllers/account_controller.ex +++ b/lib/web_analytics_web/controllers/account_controller.ex @@ -4,160 +4,32 @@ defmodule WebAnalyticsWeb.AccountController do POST /api/v1/accounts - An AI tool that reads `llms.txt` can call this, get an account id back, and - start reporting events in the same run. Requiring a human to stop and fill in - a signup form first is the thing most likely to end an integration before it - starts, so there is no form, no key exchange and no auth header here. - - Two shapes, depending on whether a human is reachable: - - * with `email` — the magic link is mailed to that address and never returned - in the response, so the address still has to be controlled by whoever ends - up signing in; - * without `email` — the account is anonymous and the response carries a - one-time claim link, which is then the only way into it. + The rules live in `WebAnalyticsWeb.AccountProvisioning`, shared with the MCP + server; this is the HTTP shape around them. """ use WebAnalyticsWeb, :controller - require Logger - - alias WebAnalytics.Accounts - alias WebAnalytics.RateLimiter - alias WebAnalytics.Sites - - # Generous enough that a developer retrying by hand never notices, low enough - # that a script cannot fill the users table. - @limit 5 - @window_ms 3_600_000 + alias WebAnalyticsWeb.AccountProvisioning + alias WebAnalyticsWeb.ClientIP def create(conn, params) do - case RateLimiter.hit({:account_create, client_ip(conn)}, @limit, @window_ms) do - :ok -> provision(conn, params) - {:error, retry_after} -> rate_limited(conn, retry_after) - end - end - - def options(conn, _params), do: send_resp(conn, 204, "") - - defp provision(conn, params) do - email = normalize_email(params["email"] || params["contact"]) - key = Sites.generate_key() + case AccountProvisioning.create(params, ClientIP.get(conn)) do + {:ok, body, _site} -> + conn |> put_status(:created) |> json(body) - cond do - email && Accounts.get_user_by_email(email) -> - conn - |> put_status(:conflict) - |> json(%{ - error: "email_taken", - message: "That email already has an account. Sign in to see its account id.", - login_url: url(~p"/users/log-in") - }) - - true -> - do_provision(conn, email, key, params) - end - end - - defp do_provision(conn, email, key, params) do - # A reserved TLD, so an anonymous account can satisfy the unique-email - # constraint without ever addressing mail at a real person. - login_email = email || "#{key}@unclaimed.invalid" - site_attrs = %{"key" => key, "name" => site_name(params)} + {:error, :email_taken, body} -> + conn |> put_status(:conflict) |> json(body) - case Accounts.provision_account(%{email: login_email}, site_attrs) do - {:ok, user, site, token} -> - respond_created(conn, user, site, token, email) + {:error, :invalid, body} -> + conn |> put_status(:unprocessable_entity) |> json(body) - {:error, changeset} -> + {:error, {:rate_limited, retry_after}, body} -> conn - |> put_status(:unprocessable_entity) - |> json(%{ - error: "invalid", - message: "Could not create an account.", - details: errors(changeset) - }) + |> put_resp_header("retry-after", to_string(retry_after)) + |> put_status(:too_many_requests) + |> json(body) end end - defp respond_created(conn, user, site, token, email) do - body = %{ - uid: site.key, - account_id: site.key, - project: site.name, - dashboard_url: url(~p"/dashboard"), - docs_url: url(~p"/llms.txt"), - ping_url: ping_url(site) - } - - body = - if email do - Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}")) - - Map.merge(body, %{ - claim: "emailed", - message: "Account created. A sign-in link was emailed to #{email}." - }) - else - Map.merge(body, %{ - claim: "link", - claim_url: url(~p"/users/log-in/#{token}"), - message: - "Account created. Give claim_url to a human to open the dashboard — " <> - "it is the only way in, so store it or set an email in settings." - }) - end - - conn - |> put_status(:created) - |> json(body) - end - - defp ping_url(site) do - url(~p"/api/ping?#{[uid: site.key, type: "ai", project: site.name, event: "page_view"]}") - end - - defp rate_limited(conn, retry_after) do - conn - |> put_resp_header("retry-after", to_string(retry_after)) - |> put_status(:too_many_requests) - |> json(%{ - error: "rate_limited", - message: "Too many accounts created from this address. Try again later.", - retry_after: retry_after - }) - end - - defp site_name(params) do - case params["project"] || params["name"] do - value when is_binary(value) -> - case value |> String.trim() |> String.slice(0, 80) do - "" -> "My project" - trimmed -> trimmed - end - - _ -> - "My project" - end - end - - defp normalize_email(value) when is_binary(value) do - case value |> String.trim() |> String.downcase() do - "" -> nil - email -> email - end - end - - defp normalize_email(_), do: nil - - defp errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), "") |> to_string() - end) - end) - end - - defp client_ip(conn) do - conn.remote_ip |> :inet.ntoa() |> to_string() - end + def options(conn, _params), do: send_resp(conn, 204, "") end diff --git a/lib/web_analytics_web/controllers/collect_controller.ex b/lib/web_analytics_web/controllers/collect_controller.ex index fba1a79..73e735f 100644 --- a/lib/web_analytics_web/controllers/collect_controller.ex +++ b/lib/web_analytics_web/controllers/collect_controller.ex @@ -11,6 +11,7 @@ defmodule WebAnalyticsWeb.CollectController do alias WebAnalytics.Ingest alias WebAnalytics.Sites + alias WebAnalyticsWeb.ClientIP def create(conn, params) do received_at = DateTime.utc_now() @@ -20,7 +21,7 @@ defmodule WebAnalyticsWeb.CollectController do accepted(conn) site -> - ip = client_ip(conn) + ip = ClientIP.get(conn) Ingest.submit(site, params, received_at: received_at, @@ -40,22 +41,4 @@ defmodule WebAnalyticsWeb.CollectController do |> put_resp_header("cache-control", "no-store") |> send_resp(204, "") end - - # `x-forwarded-for` is client-controlled and only trusted when the deployment - # says it sits behind a proxy that overwrites it. The value is never stored - # raw — it is salted and hashed — so a spoofed header costs nothing beyond a - # slightly noisier anomaly signal. - defp client_ip(conn) do - if Application.get_env(:web_analytics, :trust_proxy_headers, false) do - case get_req_header(conn, "x-forwarded-for") do - [value | _] -> value |> String.split(",") |> List.first() |> String.trim() - [] -> remote_ip(conn) - end - else - remote_ip(conn) - end - end - - defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil - defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string() end diff --git a/lib/web_analytics_web/controllers/landing_controller.ex b/lib/web_analytics_web/controllers/landing_controller.ex index f6f0c7f..afc04c5 100644 --- a/lib/web_analytics_web/controllers/landing_controller.ex +++ b/lib/web_analytics_web/controllers/landing_controller.ex @@ -40,6 +40,19 @@ defmodule WebAnalyticsWeb.LandingController do |> render(:ai) end + def mcp(conn, _params) do + conn + |> assign(:page_title, "Analytics MCP Server — free web & AI agent analytics") + |> assign( + :page_description, + "A free remote MCP server for web analytics and AI agent analytics. Track events, " <> + "pageviews, users and revenue, and ask Claude, Cursor, VS Code or ChatGPT about your traffic." + ) + |> assign(:base_url, base_url(conn)) + |> assign(:tools, WebAnalyticsWeb.MCP.Tools.definitions()) + |> render(:mcp) + end + # The canonical host is written into priv/docs/llms.txt literally, so the file # reads correctly when browsed on GitHub. It is still rewritten per deployment # here: a self-hosted instance serving the canonical URL would be telling its @@ -47,7 +60,7 @@ defmodule WebAnalyticsWeb.LandingController do @canonical_url "https://seriouslysimpleanalytics.com" def llms(conn, _params) do - body = String.replace(@llms, @canonical_url, base_url(conn)) + body = llms_text(base_url(conn)) conn |> put_resp_content_type("text/plain") @@ -55,6 +68,9 @@ defmodule WebAnalyticsWeb.LandingController do |> send_resp(200, body) end + @doc "llms.txt with this deployment's URL in it. Also served as an MCP resource." + def llms_text(base_url), do: String.replace(@llms, @canonical_url, base_url) + # Real numbers from this deployment's own account, or nil when self-tracking # is not configured. Nil hides the panel rather than filling it with zeros, # since an empty proof is worse than no proof. diff --git a/lib/web_analytics_web/controllers/landing_html.ex b/lib/web_analytics_web/controllers/landing_html.ex index 6c47af2..99f0558 100644 --- a/lib/web_analytics_web/controllers/landing_html.ex +++ b/lib/web_analytics_web/controllers/landing_html.ex @@ -21,7 +21,7 @@ defmodule WebAnalyticsWeb.LandingHTML do def ai_path, do: @ai_path attr :current_scope, :map, default: nil - attr :active, :atom, default: :web, values: [:web, :ai] + attr :active, :atom, default: :web, values: [:web, :ai, :mcp] @doc """ The header shared by both landing pages. @@ -49,6 +49,12 @@ defmodule WebAnalyticsWeb.LandingHTML do > AI tool analytics + + MCP server + llms.txt @@ -163,6 +169,7 @@ defmodule WebAnalyticsWeb.LandingHTML do · Website analytics AI tool analytics + MCP server <.link navigate={~p"/dashboard"} class="link link-hover">Dashboard llms.txt @@ -177,6 +184,41 @@ defmodule WebAnalyticsWeb.LandingHTML do """ end + @doc "How to add the MCP server to the clients people actually use." + def mcp_clients(base_url) do + endpoint = base_url <> "/mcp" + + [ + {"Claude Code", + "claude mcp add --transport http seriouslysimpleanalytics #{endpoint} \\\n --header \"Authorization: Bearer YOUR_API_KEY\""}, + {"Cursor — ~/.cursor/mcp.json", + Jason.encode!( + %{ + "mcpServers" => %{ + "seriouslysimpleanalytics" => %{ + "url" => endpoint, + "headers" => %{"Authorization" => "Bearer YOUR_API_KEY"} + } + } + }, + pretty: true + )}, + {"VS Code — .vscode/mcp.json", + Jason.encode!( + %{ + "servers" => %{ + "seriouslysimpleanalytics" => %{ + "type" => "http", + "url" => endpoint, + "headers" => %{"Authorization" => "Bearer YOUR_API_KEY"} + } + } + }, + pretty: true + )} + ] + end + @doc "The install snippet, for sites that also want browser tracking." def script_tag(base_url, site_key) do ~s|| diff --git a/lib/web_analytics_web/controllers/landing_html/mcp.html.heex b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex new file mode 100644 index 0000000..b157a5b --- /dev/null +++ b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex @@ -0,0 +1,156 @@ +
+ <.site_header current_scope={@current_scope} active={:mcp} /> + + +
+
+

Analytics MCP Server

+

+ Your analytics, inside Claude, Cursor and ChatGPT +

+

+ SeriouslySimpleAnalytics is a free, unlimited Model Context Protocol server for web + analytics and AI agent analytics. Your assistant can create an account, add tracking + to a project, record events, and answer "how is my site doing?" from real traffic — + visitors, top pages, referrers, custom events, revenue metrics, individual users and + AI crawlers. +

+
+ +
+
Remote MCP endpoint
+
+
{@base_url}/mcp
+ +
+

+ Streamable HTTP. Tracking tools need nothing; report tools need your account's API key as + Authorization: Bearer ssa_… + — create one on <.link navigate={~p"/getting-started"} class="link">Getting started, or let + create_analytics_account + hand your assistant one. +

+

+ Listed in the official MCP Registry as com.seriouslysimpleanalytics/analytics. +

+
+
+ + +
+
+

Connect it

+

+ Any MCP client that speaks Streamable HTTP works. In Claude or ChatGPT, add a custom + connector with the endpoint above: tracking works straight away, and the report tools + need a client that can send a header. +

+ +
+
+
+

{client}

+ +
+
{config}
+
+
+
+
+ + +
+

{length(@tools)} tools

+

+ Three that write, which need no key, and the rest read the reports your dashboard shows. + Every report narrows by time range, project, domain and user. +

+ +
+ + + + + + + + + + + + + + + +
ToolWhat it answersKey
+
{tool["name"]}
+
{tool["title"]}
+
+ {String.replace_suffix(tool["description"], " Needs the account's API key.", "")} + + {if tool["annotations"]["readOnlyHint"] and tool["name"] != "get_integration_guide", + do: "API key", + else: "none"} +
+
+
+ + +
+
+
+

What does it cost?

+

+ Nothing, and there is no traffic cap. The MCP server, the website tracker and the + event API are the same free account. +

+
+
+

What can it track?

+

+ Websites, through one script tag, and anything else through one URL: AI agents, MCP + servers, CLIs, backends and cron jobs. Pageviews, custom events, numeric metrics like + revenue or tokens, and per-user activity. +

+
+
+

Why is the API key only for reading?

+

+ An account ID is public — it sits in the page source of every tracked site — so it can + only ever allow sending. Reading a report back needs a secret, shown once and stored + only as a hash. +

+
+
+

Is there a non-MCP way in?

+

+ Yes. llms.txt + documents the plain HTTP event API, and the + <.link navigate={~p"/dashboard"} class="link">dashboard + shows everything the tools read. +

+
+
+
+ + <.site_footer /> + + <%!-- Carries the copy buttons, as on the other landing pages. --%> + +
diff --git a/lib/web_analytics_web/controllers/mcp_controller.ex b/lib/web_analytics_web/controllers/mcp_controller.ex new file mode 100644 index 0000000..a3df4fb --- /dev/null +++ b/lib/web_analytics_web/controllers/mcp_controller.ex @@ -0,0 +1,135 @@ +defmodule WebAnalyticsWeb.MCPController do + @moduledoc """ + The MCP server's Streamable HTTP transport, at `/mcp`. + + One POST per JSON-RPC message, answered with `application/json`. There is no + server-to-client stream to open: every tool here finishes in one request, and + the server keeps no session, so a GET or DELETE on the endpoint is answered + with 405 as both protocol eras allow. + + Origins are all accepted, deliberately. The rule that servers validate + `Origin` exists to stop DNS rebinding against servers on a private network, + which reach things only the visitor's browser can. This one is on the public + internet, reads no cookies, and unlocks nothing without a key the calling page + would have to already hold — a hostile page gets exactly what curl gets. + """ + use WebAnalyticsWeb, :controller + + alias WebAnalytics.ApiKeys + alias WebAnalyticsWeb.ClientIP + alias WebAnalyticsWeb.MCP.Server + + @auth_path Path.expand("../../../priv/mcp/mcp-registry-auth", __DIR__) + @external_resource @auth_path + @registry_auth (case File.read(@auth_path) do + {:ok, contents} -> String.trim(contents) + {:error, _} -> nil + end) + + def handle(conn, _params) do + ctx = context(conn) + + case conn.body_params do + # JSON-RPC batches existed only in 2025-03-26. Each message is handled on + # its own and the answers are returned together, notifications omitted. + %{"_json" => messages} when is_list(messages) and messages != [] -> + replies = + messages + |> Enum.map(&Server.handle(&1, ctx)) + |> Enum.flat_map(fn + {_status, nil} -> [] + {_status, body} -> [body] + end) + + if replies == [], do: send_resp(conn, 202, ""), else: reply(conn, 200, replies) + + message when is_map(message) and map_size(message) > 0 -> + case Server.handle(message, ctx) do + {status, nil} -> send_resp(conn, status, "") + {status, body} -> reply(conn, status, body) + end + + _ -> + reply( + conn, + 400, + Server.error(nil, -32700, "Parse error: expected a JSON-RPC message body") + ) + end + end + + # A person who opens the endpoint in a browser gets the page about it; a + # client asking for a server-to-client stream gets told there is none. + def stream(conn, _params) do + if conn.method == "GET" and html?(conn) do + redirect(conn, to: ~p"/analytics-mcp-server") + else + conn + |> put_resp_header("allow", "POST, OPTIONS") + |> reply( + 405, + Server.error(nil, -32000, "Method not allowed: POST JSON-RPC messages to this endpoint") + ) + end + end + + def options(conn, _params), do: send_resp(conn, 204, "") + + def registry_auth(conn, _params) do + case @registry_auth do + nil -> + send_resp(conn, 404, "") + + proof -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(200, proof) + end + end + + defp context(conn) do + {auth, site} = authenticate(conn) + + %{ + base_url: conn |> url(~p"/") |> String.trim_trailing("/"), + ip: ClientIP.get(conn), + headers: conn.req_headers, + auth: auth, + site: site + } + end + + defp authenticate(conn) do + token = + case get_req_header(conn, "authorization") do + ["Bearer " <> token | _] -> String.trim(token) + ["bearer " <> token | _] -> String.trim(token) + _ -> conn |> get_req_header("x-api-key") |> List.first() + end + + case token do + nil -> + {:none, nil} + + "" -> + {:none, nil} + + token -> + case ApiKeys.authenticate(token) do + {:ok, site} -> {:valid, site} + :error -> {:invalid, nil} + end + end + end + + defp html?(conn) do + conn |> get_req_header("accept") |> Enum.any?(&String.contains?(&1, "text/html")) + end + + defp reply(conn, status, body) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end +end diff --git a/lib/web_analytics_web/controllers/ping_controller.ex b/lib/web_analytics_web/controllers/ping_controller.ex index 387dac3..a2966de 100644 --- a/lib/web_analytics_web/controllers/ping_controller.ex +++ b/lib/web_analytics_web/controllers/ping_controller.ex @@ -16,283 +16,17 @@ defmodule WebAnalyticsWeb.PingController do """ use WebAnalyticsWeb, :controller - alias WebAnalytics.Geo - alias WebAnalytics.Geo.Countries - alias WebAnalytics.Ingest - alias WebAnalytics.Sites - - # Names that mean "someone looked at something", which are recorded as real - # pageviews so they land in the pages and flow reports rather than only in the - # event list. - @pageview_events ~w(page_view pageview pv view screen screen_view) - - @reserved ~w(uid id site u type channel project app event name sid session path page - title ref referrer visitor v format bot agent ai tz timezone - email contact user user_id userid - c city cc county s_p state province region n nation country) + alias WebAnalytics.Ingest.Ping + alias WebAnalyticsWeb.ClientIP # A 1x1 transparent GIF, for callers that can only embed an image. @pixel Base.decode64!("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") - # How long consecutive pings from the same caller are treated as one session - # when no session id is supplied. Thirty minutes is the convention analytics - # has used for inactivity windows for decades. - @session_window_seconds 1_800 - + # What a ping means lives in WebAnalytics.Ingest.Ping, shared with the MCP + # server's track_event tool. This module is only the HTTP shape around it. def ping(conn, params) do - received_at = DateTime.utc_now() - - case Sites.fetch_site_by_key(account_id(params)) do - nil -> - respond(conn, params) - - site -> - ip = client_ip(conn) - ip_hash = Ingest.hash_ip(ip, site) - - Ingest.submit(site, payload(params, received_at, session_token(params, site, ip_hash)), - received_at: received_at, - user_id: user_id(params), - user_traits: user_traits(params), - ip_hash: ip_hash, - ip_masked: Ingest.mask_ip(ip), - location: location(params, conn, ip), - project: param(params, ~w(project app)), - channel: param(params, ~w(type channel)) || "ai", - agent_name: param(params, ~w(name agent ai)), - contact_email: param(params, ~w(email contact)) - ) - - respond(conn, params) - end - end - - # -- user ---------------------------------------------------------------- - - @doc false - # The caller's own ID for who this ping is about: an account, a customer, a - # mailbox. Accepted as a number as well as a string, because a JSON body - # carries ids as numbers far more often than not, and param/2 — built for - # query strings — would drop `"user": 42` without a word. - # - # `visitor` is the older name for the same idea and still counts, so a tool - # already sending it shows up by user without changing anything. - defp user_id(params) do - Enum.find_value(~w(user user_id userid visitor), fn key -> - case Map.get(params, key) do - value when is_integer(value) -> Integer.to_string(value) - value when is_binary(value) -> blank_to_nil(value) - _ -> nil - end - end) - end - - @doc false - # Every other identifier, by prefix: user_domain, user_address, user_plan. - # One convention rather than a fixed list, because what identifies a user is - # the caller's business — an email provider has an account, a domain and an - # address; a marketplace has a seller and a wallet. - # - # They stay on the event as ordinary attributes too. This copy is what lets - # the dashboard show them beside the user without reading every event. - defp user_traits(params) do - params - |> Enum.filter(fn {key, value} -> - is_binary(key) and String.starts_with?(key, "user_") and key not in @reserved and - (is_binary(value) or is_number(value) or is_boolean(value)) - end) - |> Map.new(fn {key, value} -> - {String.replace_prefix(key, "user_", ""), to_string(value)} - end) - end - - defp blank_to_nil(value) do - case String.trim(value) do - "" -> nil - trimmed -> trimmed - end - end - - # -- location ------------------------------------------------------------ - - @doc false - # A caller's own location parameters always win over anything this server - # could work out. - # - # That is not a preference, it is the only correct answer for this endpoint: - # a ping arrives from wherever the tool runs — a laptop, a container, a - # serverless region three countries away — so its source address says where - # the *software* is, not where its user is. Resolving the address would - # produce a confident, wrong answer. Falling back to it at all is only - # reasonable for the browser tracker, where the connection really is the - # visitor's. - defp location(params, conn, ip) do - supplied = %{ - city: param(params, ~w(c city)), - county: param(params, ~w(cc county)), - region: param(params, ~w(s_p state province region)), - country: param(params, ~w(n nation country)) - } - - if Enum.any?(Map.values(supplied), &is_binary/1) do - code = Countries.code_for(supplied.country) - - %{ - Geo.empty() - | city: supplied.city, - county: supplied.county, - region: supplied.region, - country: Countries.name(code) || supplied.country, - country_code: code, - source: "client" - } - else - Ingest.locate(conn.req_headers, ip) - end - end - - # -- payload ------------------------------------------------------------- - - defp payload(params, received_at, token) do - # `name` is deliberately NOT an alias for `event` any more. It now identifies - # the AI tool doing the reporting, and one parameter cannot mean two things: - # a caller sending name=Claude would otherwise have silently renamed its - # event instead of identifying itself. - event_name = param(params, ~w(event)) || "ping" - path = param(params, ~w(path page)) - now = DateTime.to_unix(received_at, :millisecond) - - %{ - "k" => account_id(params), - "s" => token, - # A user id is the best visitor identity a ping can have: it is what makes - # "visitors" count people rather than sessions for a tool that says who - # it is acting for. - "v" => param(params, ~w(visitor v)) || user_id(params), - "t" => now, - "e" => [init_event(params, now) | [body_event(event_name, path, params, now)]] - } - end - - # The caller's user agent is deliberately *not* forwarded for classification. - # A ping is a tool reporting its own usage, and most such callers are a script - # or an HTTP library — classifying them by user agent would file every one as - # a crawler and quietly filter the owner's own telemetry out of their own - # reports. Only an explicit `bot=` marks a ping as automated. - defp init_event(params, now) do - %{ - "n" => "init", - "t" => now, - "ref" => param(params, ~w(ref referrer)), - "bot" => param(params, ~w(bot agent)), - "tz" => param(params, ~w(tz timezone)), - "hb" => 10_000 - } - end - - # `event=page_view` with a path is a real pageview; everything else is a - # named event. That mapping is documented, so a caller gets pages and flow - # reporting by naming the event the obvious thing rather than by learning a - # second parameter. - defp body_event(event_name, path, params, now) do - if String.downcase(event_name) in @pageview_events and path do - # No `seq` and no `from`: the server continues the session's sequence and - # links this page to the one before it, which is what builds the flow - # graph without the caller having to track any of it. - %{ - "n" => "pv", - "t" => now, - "path" => path, - "title" => param(params, ~w(title)), - "ref" => param(params, ~w(ref referrer)) - } - else - # No `pv` either: the event attaches to whatever page the session is on. - %{ - "n" => "event", - "t" => now, - "name" => event_name, - "text" => param(params, ~w(title)), - "data" => extras(params) - } - end - end - - # Anything the caller invented, kept as attributes. - defp extras(params) do - params - |> Enum.reject(fn {key, value} -> key in @reserved or is_nil(value) end) - |> Enum.take(20) - |> Map.new(fn {key, value} -> {to_string(key), attribute_value(value)} end) - end - - # A query string only ever carries strings, but a JSON body carries whatever - # the caller put in it. to_string/1 has no clause for a map or a list, so - # "meta": {"repo": "x"} raised — and the whole ping was lost with a 500, not - # just the field that could not be stored. An agent posting structured - # context would have been silently dropping every event it sent. - # - # Nested values are kept as their JSON rather than thrown away, capped so one - # large blob cannot bloat a row. They never become metrics — a key is a - # metric only when its values are plain numbers — which is correct: there is - # nothing to sum in an object. - defp attribute_value(value) when is_binary(value), do: value - defp attribute_value(value) when is_number(value) or is_boolean(value), do: to_string(value) - - defp attribute_value(value) when is_map(value) or is_list(value) do - value |> Jason.encode!() |> String.slice(0, 1_000) - end - - defp attribute_value(value), do: inspect(value) - - @doc false - # A session id from the caller is authoritative. Without one, pings are grouped - # by who and what they came from within a rolling half-hour, so that a tool - # that never passes `sid` still produces sessions — and therefore page flow — - # rather than a pile of one-event sessions that no report can connect. - # - # The window tumbles rather than sliding, so a long run can straddle a boundary - # and split in two. That is the price of deriving a session without a lookup on - # every ping; `sid` is there for callers that need exactness. - defp session_token(params, site, ip_hash) do - case param(params, ~w(sid session)) do - nil -> derived_token(params, site, ip_hash) - explicit -> explicit - end - end - - # A user id comes first. A backend reporting for many users sends every ping - # from one address, and grouping by address would fold all of them into a - # single session — one "user" doing everything at once. - defp derived_token(params, site, ip_hash) do - project = param(params, ~w(project app)) || "-" - who = user_id(params) || param(params, ~w(visitor v)) || ip_hash || "anon" - window = div(System.system_time(:second), @session_window_seconds) - - digest = - :sha256 - |> :crypto.hash([site.key, "|", project, "|", who, "|", Integer.to_string(window)]) - |> Base.url_encode64(padding: false) - |> binary_part(0, 22) - - "auto-" <> digest - end - - defp account_id(params), do: param(params, ~w(uid id site u)) - - defp param(params, keys) do - Enum.find_value(keys, fn key -> - case Map.get(params, key) do - value when is_binary(value) -> - case String.trim(value) do - "" -> nil - trimmed -> trimmed - end - - _ -> - nil - end - end) + Ping.submit(params, ip: ClientIP.get(conn), headers: conn.req_headers) + respond(conn, params) end # -- response ------------------------------------------------------------ @@ -302,7 +36,7 @@ defmodule WebAnalyticsWeb.PingController do defp respond(conn, params) do conn = put_resp_header(conn, "cache-control", "no-store, no-cache, must-revalidate") - case param(params, ~w(format)) do + case params["format"] do "gif" -> conn |> put_resp_content_type("image/gif") |> send_resp(200, @pixel) @@ -313,18 +47,4 @@ defmodule WebAnalyticsWeb.PingController do send_resp(conn, 204, "") end end - - defp client_ip(conn) do - if Application.get_env(:web_analytics, :trust_proxy_headers, false) do - case get_req_header(conn, "x-forwarded-for") do - [value | _] -> value |> String.split(",") |> List.first() |> String.trim() - [] -> remote_ip(conn) - end - else - remote_ip(conn) - end - end - - defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil - defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string() end diff --git a/lib/web_analytics_web/live/getting_started_live.ex b/lib/web_analytics_web/live/getting_started_live.ex index 845b02b..54502b8 100644 --- a/lib/web_analytics_web/live/getting_started_live.ex +++ b/lib/web_analytics_web/live/getting_started_live.ex @@ -12,6 +12,7 @@ defmodule WebAnalyticsWeb.GettingStartedLive do import WebAnalyticsWeb.IntegrationComponents, only: [integration_chat: 1] + alias WebAnalytics.ApiKeys alias WebAnalytics.Sites @impl true @@ -30,7 +31,42 @@ defmodule WebAnalyticsWeb.GettingStartedLive do @impl true def handle_params(params, _uri, socket) do - {:noreply, assign(socket, :site, resolve_site(socket.assigns.sites, params["site"]))} + site = resolve_site(socket.assigns.sites, params["site"]) + + {:noreply, + socket + |> assign(:site, site) + |> assign(:api_keys, ApiKeys.list(site)) + # The one moment a key exists in readable form. Kept only in this socket, + # so navigating away or reloading is the end of it. + |> assign(:new_token, nil)} + end + + @impl true + def handle_event("create_api_key", params, socket) do + case ApiKeys.create(socket.assigns.site, params["name"]) do + {:ok, token, _key} -> + {:noreply, + socket + |> assign(:new_token, token) + |> assign(:api_keys, ApiKeys.list(socket.assigns.site))} + + {:error, :too_many} -> + {:noreply, + put_flash(socket, :error, "Revoke an unused key first — an account can have 20.")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not create a key.")} + end + end + + def handle_event("revoke_api_key", %{"id" => id}, socket) do + ApiKeys.revoke(socket.assigns.site, id) + {:noreply, assign(socket, :api_keys, ApiKeys.list(socket.assigns.site))} + end + + def handle_event("dismiss_token", _params, socket) do + {:noreply, assign(socket, :new_token, nil)} end defp resolve_site(sites, key) do @@ -43,6 +79,11 @@ defmodule WebAnalyticsWeb.GettingStartedLive do ~s|| end + defp claude_command(endpoint, token) do + "claude mcp add --transport http seriouslysimpleanalytics #{endpoint}/mcp " <> + "--header \"Authorization: Bearer #{token}\"" + end + defp agent_prompt(site, endpoint) do WebAnalyticsWeb.DashboardLive.agent_prompt(site, endpoint) end diff --git a/lib/web_analytics_web/live/getting_started_live.html.heex b/lib/web_analytics_web/live/getting_started_live.html.heex index ee37a12..caa3732 100644 --- a/lib/web_analytics_web/live/getting_started_live.html.heex +++ b/lib/web_analytics_web/live/getting_started_live.html.heex @@ -57,6 +57,119 @@ + <%!-- Reading reports is the one thing an account ID must never allow, so + it is the one thing here that needs a secret. --%> +
+
+

MCP Server

+

+ Connect Claude, Cursor, VS Code or any MCP client to this account at {base_url()}/mcp. + Tracking events needs no key. Asking about your traffic, events, metrics and users + needs an API key — create one per client, and revoke it here when you stop using it. + <.link navigate={~p"/analytics-mcp-server"} class="link">Setup for each client +

+
+ +
+
+ Copy this key now — it will not be shown again. + + +
+
+
{@new_token}
+ +
+
+ Or add the server to Claude Code in one line: +
+
+
{claude_command(base_url(), @new_token)}
+ +
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameKeyCreatedLast used
{key.name}{key.prefix}… + {Calendar.strftime(key.inserted_at, "%Y-%m-%d")} + + {if key.last_used_at, + do: Calendar.strftime(key.last_used_at, "%Y-%m-%d %H:%M"), + else: "Never"} + + Revoked + +
+
+
+

The full contract an agent reads is at base_url <> "/llms.txt", + "name" => "llms.txt", + "title" => "Integration guide (llms.txt)", + "description" => + "The complete contract for adding web analytics or AI agent analytics to a project: " <> + "the event API, every parameter, sessions, users, metrics, and what never to send.", + "mimeType" => "text/markdown" + } + ] + end + + @doc "Reads a resource by URI." + def read_resource(uri, base_url) do + if uri == base_url <> "/llms.txt" do + {:ok, + [ + %{ + "uri" => uri, + "mimeType" => "text/markdown", + "text" => LandingController.llms_text(base_url) + } + ]} + else + :error + end + end + + @doc "Prompts for prompts/list." + def prompts do + [ + %{ + "name" => "add_analytics", + "title" => "Add analytics to this project", + "description" => + "Instrument the current project with free web analytics and AI agent analytics: the " <> + "website tag, events at the right places in the code, and the llms.txt handoff.", + "arguments" => [ + %{"name" => "project", "description" => "The project's name.", "required" => false}, + %{ + "name" => "account_id", + "description" => "An existing account ID, if there is one.", + "required" => false + } + ] + }, + %{ + "name" => "analytics_report", + "title" => "Analytics report", + "description" => + "Summarise traffic, top pages, sources, events, metrics and users for a time range, and " <> + "call out what changed.", + "arguments" => [ + %{ + "name" => "range", + "description" => "1h, 24h, 7d, 30d or all. Defaults to 7d.", + "required" => false + } + ] + } + ] + end + + @doc "Renders a prompt." + def get_prompt("add_analytics", arguments, base_url) do + project = present(arguments["project"]) || "this project" + + account = + case present(arguments["account_id"]) do + nil -> + "Create an account with the create_analytics_account tool (one for the project, reused forever)." + + key -> + "Use the existing account ID #{key}." + end + + text = """ + Add SeriouslySimpleAnalytics to #{project}. + + 1. #{account} + 2. Read the integration contract, #{base_url}/llms.txt (also available as a resource on this server), and follow it. + 3. If the project has a website, add the script tag from get_integration_guide to its shared layout. + 4. Report events from where they happen in the code: first run, run started and completed with an outcome, each tool call, and errors by kind. Reuse one session ID per run, and send `user` with your own ID for whoever each event is about. + 5. Never send credentials, prompts or completions. + 6. Add the block from "Put this in your own llms.txt" to the project's llms.txt or AGENTS.md. + 7. Send one test event with track_event and confirm it was recorded. + """ + + {:ok, message("Add analytics to #{project}", text)} + end + + def get_prompt("analytics_report", arguments, _base_url) do + range = if arguments["range"] in ~w(1h 24h 7d 30d all), do: arguments["range"], else: "7d" + + text = """ + Write an analytics report for the last #{range} using the SeriouslySimpleAnalytics tools. + + Call get_analytics_overview first, then get_traffic_timeseries, get_top_pages, get_traffic_sources, get_events and get_metrics for the same range. If list_users returns anyone, include the most active users. + + Lead with the three things that matter most, then the numbers behind them. Compare against the previous period where the data allows, and say plainly when there is too little traffic to conclude anything. + """ + + {:ok, message("Analytics report (#{range})", text)} + end + + def get_prompt(_name, _arguments, _base_url), do: :error + + defp message(description, text) do + %{ + "description" => description, + "messages" => [ + %{"role" => "user", "content" => %{"type" => "text", "text" => String.trim(text)}} + ] + } + end + + defp present(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present(_), do: nil +end diff --git a/lib/web_analytics_web/mcp/server.ex b/lib/web_analytics_web/mcp/server.ex new file mode 100644 index 0000000..99568ee --- /dev/null +++ b/lib/web_analytics_web/mcp/server.ex @@ -0,0 +1,329 @@ +defmodule WebAnalyticsWeb.MCP.Server do + @moduledoc """ + The Model Context Protocol server: SeriouslySimpleAnalytics as tools an AI + assistant can call. + + Transport-free — it takes one decoded JSON-RPC message and a context, and + returns an HTTP status with a body — so the protocol can be tested without a + connection and `WebAnalyticsWeb.MCPController` stays a thin shell. + + Two eras of the protocol are spoken at once, because clients in the wild are + on both: + + * **Stateless (2026-07-28).** No handshake. Every request carries its + protocol version in `params._meta`, `server/discover` replaces + `initialize`, and every result says `resultType: "complete"`. + * **Handshake (2024-11-05 to 2025-11-25).** `initialize` negotiates a + version, notifications are acknowledged with 202, and `ping` exists. + + A request is modern if and only if it carries + `io.modelcontextprotocol/protocolVersion` in its `_meta`. That field is + required in the new era and unknown in the old one, so it cannot misfire. + + The server holds no session state in either era. Tools, resources and prompts + are the same for every caller; what an API key changes is which account the + reading tools answer for, and that is decided per request. + """ + + alias WebAnalyticsWeb.MCP.Catalog + alias WebAnalyticsWeb.MCP.Tools + + @version "1.0.0" + @modern_versions ["2026-07-28"] + @legacy_versions ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + + # A day. The tool list only changes with a deploy, and a deploy bumps nothing + # a client could see sooner than that anyway. + @list_ttl_ms 86_400_000 + + @doc "This server's version, as published to the MCP Registry." + def version, do: @version + + @doc "Every protocol version this server speaks, newest first." + def supported_versions, do: @modern_versions ++ @legacy_versions + + @doc "The server's identity, as MCP `Implementation`." + def server_info(base_url) do + %{ + "name" => "seriouslysimpleanalytics", + "title" => "SeriouslySimpleAnalytics — Web & AI Agent Analytics", + "version" => @version, + "websiteUrl" => base_url <> "/analytics-mcp-server", + "icons" => [ + %{ + "src" => base_url <> "/images/icon-512.png", + "mimeType" => "image/png", + "sizes" => ["512x512"] + } + ] + } + end + + @doc "What the server tells a model about itself." + def capabilities, do: %{"tools" => %{}, "resources" => %{}, "prompts" => %{}} + + @doc """ + Handles one JSON-RPC message. + + `ctx` is a map with `:base_url`, `:ip`, `:headers` (lowercased names), + `:site` (the account an API key unlocked, or nil) and `:auth` (`:none`, + `:valid` or `:invalid`). + + Returns `{status, body}`; a nil body means the response has none. + """ + def handle(%{"jsonrpc" => "2.0", "method" => method} = message, ctx) when is_binary(method) do + params = if is_map(message["params"]), do: message["params"], else: %{} + meta = if is_map(params["_meta"]), do: params["_meta"], else: %{} + + request = %{ + id: message["id"], + notification?: not Map.has_key?(message, "id"), + method: method, + params: params + } + + case meta["io.modelcontextprotocol/protocolVersion"] do + version when is_binary(version) -> modern(request, version, ctx) + _ -> legacy(request, ctx) + end + end + + # A client's answer to a request this server never sends. Acknowledged the + # way the handshake era says to acknowledge any response. + def handle(%{"jsonrpc" => "2.0", "id" => _} = message, _ctx) + when is_map_key(message, "result") or is_map_key(message, "error") do + {202, nil} + end + + def handle(_message, _ctx) do + {400, error(nil, -32600, "Invalid Request: expected a JSON-RPC 2.0 request object")} + end + + # -- 2026-07-28 ---------------------------------------------------------- + + defp modern(request, version, ctx) do + cond do + mismatch?(ctx, "mcp-protocol-version", version) -> + {400, error(request.id, -32020, "MCP-Protocol-Version header does not match _meta")} + + mismatch?(ctx, "mcp-method", request.method) -> + {400, error(request.id, -32020, "Mcp-Method header does not match the request method")} + + name_mismatch?(ctx, request) -> + {400, error(request.id, -32020, "Mcp-Name header does not match the request")} + + version not in @modern_versions -> + {400, + error(request.id, -32022, "Unsupported protocol version", %{ + "supported" => supported_versions(), + "requested" => version + })} + + # This revision defines no client notifications over HTTP; accepting one + # costs nothing and rejecting it helps nobody. + request.notification? -> + {202, nil} + + true -> + case dispatch(request, ctx, :modern) do + {:ok, result} -> + {200, reply(request.id, complete(result, ctx))} + + {:error, :method_not_found} -> + {404, error(request.id, -32601, "Method not found: #{request.method}")} + + {:error, code, message} -> + {200, error(request.id, code, message)} + end + end + end + + # The header only has to agree with the body when it is sent at all: a + # client too old to send it is handled by the version check, not here. + defp mismatch?(ctx, header, expected) do + case header(ctx, header) do + nil -> false + value -> value != expected + end + end + + defp name_mismatch?(ctx, %{method: method, params: params}) + when method in ["tools/call", "prompts/get", "resources/read"] do + expected = params["name"] || params["uri"] + + case header(ctx, "mcp-name") do + nil -> false + # Values that are not header-safe arrive Base64-wrapped; those are not + # compared rather than guessed at. + "=?base64?" <> _ -> false + value -> value != expected + end + end + + defp name_mismatch?(_ctx, _request), do: false + + defp complete(result, ctx) do + result + |> Map.put("resultType", "complete") + |> Map.update( + "_meta", + %{"io.modelcontextprotocol/serverInfo" => server_info(ctx.base_url)}, + fn meta -> + Map.put(meta, "io.modelcontextprotocol/serverInfo", server_info(ctx.base_url)) + end + ) + end + + # -- 2024-11-05 through 2025-11-25 -------------------------------------- + + defp legacy(request, ctx) do + header_version = header(ctx, "mcp-protocol-version") + + cond do + header_version && header_version not in @legacy_versions -> + {400, + error(request.id, -32600, "Unsupported MCP-Protocol-Version: #{header_version}", %{ + "supported" => supported_versions() + })} + + request.notification? -> + {202, nil} + + true -> + case dispatch(request, ctx, :legacy) do + {:ok, result} -> + {200, reply(request.id, result)} + + {:error, :method_not_found} -> + {200, error(request.id, -32601, "Method not found: #{request.method}")} + + {:error, code, message} -> + {200, error(request.id, code, message)} + end + end + end + + # -- methods ------------------------------------------------------------- + + defp dispatch(%{method: "initialize", params: params}, ctx, :legacy) do + requested = params["protocolVersion"] + version = if requested in @legacy_versions, do: requested, else: hd(@legacy_versions) + + {:ok, + %{ + "protocolVersion" => version, + "capabilities" => capabilities(), + "serverInfo" => server_info(ctx.base_url), + "instructions" => instructions() + }} + end + + defp dispatch(%{method: "ping"}, _ctx, :legacy), do: {:ok, %{}} + + defp dispatch(%{method: "server/discover"}, _ctx, _era) do + {:ok, + cacheable(%{ + "supportedVersions" => supported_versions(), + "capabilities" => capabilities(), + "instructions" => instructions() + })} + end + + defp dispatch(%{method: "tools/list"}, _ctx, era) do + {:ok, maybe_cacheable(%{"tools" => Tools.definitions()}, era)} + end + + defp dispatch(%{method: "tools/call", params: params}, ctx, _era) do + case params do + %{"name" => name} when is_binary(name) -> + arguments = if is_map(params["arguments"]), do: params["arguments"], else: %{} + + case Tools.call(name, arguments, ctx) do + {:ok, result} -> {:ok, result} + {:error, :unknown_tool} -> {:error, -32602, "Unknown tool: #{name}"} + end + + _ -> + {:error, -32602, "tools/call needs a tool name"} + end + end + + defp dispatch(%{method: "resources/list"}, ctx, era) do + {:ok, maybe_cacheable(%{"resources" => Catalog.resources(ctx.base_url)}, era)} + end + + defp dispatch(%{method: "resources/templates/list"}, _ctx, era) do + {:ok, maybe_cacheable(%{"resourceTemplates" => []}, era)} + end + + defp dispatch(%{method: "resources/read", params: params}, ctx, era) do + case Catalog.read_resource(params["uri"], ctx.base_url) do + {:ok, contents} -> + {:ok, maybe_cacheable(%{"contents" => contents}, era, 3_600_000)} + + :error -> + # -32002 before 2026-07-28, Invalid Params after. + {:error, if(era == :modern, do: -32602, else: -32002), + "Resource not found: #{params["uri"]}"} + end + end + + defp dispatch(%{method: "prompts/list"}, _ctx, era) do + {:ok, maybe_cacheable(%{"prompts" => Catalog.prompts()}, era)} + end + + defp dispatch(%{method: "prompts/get", params: params}, ctx, _era) do + case Catalog.get_prompt(params["name"], params["arguments"] || %{}, ctx.base_url) do + {:ok, prompt} -> {:ok, prompt} + :error -> {:error, -32602, "Unknown prompt: #{params["name"]}"} + end + end + + defp dispatch(_request, _ctx, _era), do: {:error, :method_not_found} + + defp maybe_cacheable(result, era, ttl \\ @list_ttl_ms) + defp maybe_cacheable(result, :modern, ttl), do: cacheable(result, ttl) + defp maybe_cacheable(result, :legacy, _ttl), do: result + + # Nothing listed differs by caller, so shared caches may keep it. + defp cacheable(result, ttl \\ @list_ttl_ms) do + Map.merge(result, %{"ttlMs" => ttl, "cacheScope" => "public"}) + end + + @doc "Guidance a client may put in front of the model." + def instructions do + """ + SeriouslySimpleAnalytics is free, unlimited web analytics and AI agent analytics. + + Writing needs no key: create_analytics_account gives an account ID (and an API key), \ + track_event records an event, a pageview or a numeric metric for any account ID, and \ + get_integration_guide returns the website script tag and the event API for wiring a \ + project up permanently. + + Reading reports needs the account's API key as an `Authorization: Bearer ssa_…` header. \ + Start with get_analytics_overview; narrow any report with range, project, domain or user. \ + get_metrics sums numbers sent with events (revenue, sats, tokens); list_users and \ + get_user_activity show individual behaviour for events sent with `user`. + + Never put credentials, prompts or completions in event attributes.\ + """ + end + + # -- JSON-RPC ------------------------------------------------------------ + + defp header(ctx, name) do + case List.keyfind(Map.get(ctx, :headers, []), name, 0) do + {_, value} when value != "" -> value + _ -> nil + end + end + + defp reply(id, result), do: %{"jsonrpc" => "2.0", "id" => id, "result" => result} + + @doc false + def error(id, code, message, data \\ nil) do + error = %{"code" => code, "message" => message} + error = if data, do: Map.put(error, "data", data), else: error + %{"jsonrpc" => "2.0", "id" => id, "error" => error} + end +end diff --git a/lib/web_analytics_web/mcp/tools.ex b/lib/web_analytics_web/mcp/tools.ex new file mode 100644 index 0000000..74f1f53 --- /dev/null +++ b/lib/web_analytics_web/mcp/tools.ex @@ -0,0 +1,923 @@ +defmodule WebAnalyticsWeb.MCP.Tools do + @moduledoc """ + The tools the MCP server offers. + + Two kinds, split by what they need: + + * **Writing** — create an account, record an event, get the integration + guide. No key, exactly like the HTTP API: an account ID is public, so + anything it allows is something anyone could already do with a GET. + * **Reading** — every report. These need the account's API key in the + `Authorization` header, because a report is the one thing an account ID + must never unlock. + + Every tool is listed to every caller, key or not. The list is then the same + for everyone, so it caches, and a model that has not been given a key can + still see what one would let it do — and is told how to get one when it tries. + + Descriptions are written for the model choosing between tools: what the tool + answers, not how it is built. + """ + + alias WebAnalytics.Analytics + alias WebAnalytics.ApiKeys + alias WebAnalytics.Ingest.Ping + alias WebAnalyticsWeb.AccountProvisioning + + @ranges ~w(1h 24h 7d 30d all) + + @report_filters %{ + "range" => %{ + "type" => "string", + "enum" => @ranges, + "default" => "7d", + "description" => "Time window: last hour, 24 hours, 7 days, 30 days, or all time." + }, + "project" => %{ + "type" => "string", + "description" => + "Only this project — the AI tool, app or agent reporting under the account. See get_account for the names." + }, + "domain" => %{ + "type" => "string", + "description" => + "Only this website hostname, for an account whose tag runs on several domains." + }, + "user" => %{ + "type" => "string", + "description" => "Only this identified user — the `user` ID sent with events." + }, + "include_bots" => %{ + "type" => "boolean", + "default" => false, + "description" => + "Include crawler and bot visits to the website. Off by default so reports show people." + } + } + + @read %{ + "readOnlyHint" => true, + "destructiveHint" => false, + "idempotentHint" => true, + "openWorldHint" => false + } + + @doc "Tool definitions for tools/list, in a fixed order." + def definitions do + Enum.map(tools(), fn tool -> + Map.take(tool, ~w(name title description inputSchema annotations)) + end) + end + + @doc "Calls a tool. Returns `{:ok, CallToolResult}` or `{:error, :unknown_tool}`." + def call(name, arguments, ctx) do + case Enum.find(tools(), &(&1["name"] == name)) do + nil -> + {:error, :unknown_tool} + + %{"access" => :key} = tool -> + with_account(ctx, fn site -> run(tool["name"], arguments, Map.put(ctx, :site, site)) end) + + tool -> + run(tool["name"], arguments, ctx) + end + end + + defp tools do + [ + %{ + "name" => "create_analytics_account", + "title" => "Create a free analytics account", + "access" => :open, + "description" => + "Create a free, unlimited SeriouslySimpleAnalytics account for web analytics and AI agent " <> + "analytics, with no signup form. Returns the account ID used to track events and install " <> + "the website tag, an API key for reading reports through this server, and a one-time " <> + "claim link to the dashboard (or emails a sign-in link when `email` is given). Create " <> + "one account per project and reuse it — never one per run.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "project" => %{ + "type" => "string", + "description" => "Name of the website, app or AI tool being tracked." + }, + "email" => %{ + "type" => "string", + "description" => + "Optional. The address of the person who will read the dashboard; a sign-in link is mailed there." + } + }, + "required" => ["project"] + }, + "annotations" => %{ + "readOnlyHint" => false, + "destructiveHint" => false, + "idempotentHint" => false, + "openWorldHint" => false + } + }, + %{ + "name" => "track_event", + "title" => "Track an event, pageview or metric", + "access" => :open, + "description" => + "Record one analytics event: a pageview, a tool call, a signup, a purchase, an error — " <> + "anything that happened in a website, app or AI agent. Numeric attributes are summed " <> + "and charted as metrics (revenue, tokens, sats); `user` attributes the event to a person " <> + "or account for per-user analytics. Events sharing a session_id form one session with " <> + "page flow. Send the amount for this one event, never a running total, and never " <> + "credentials, prompts or completions.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "account_id" => %{ + "type" => "string", + "description" => + "The account ID (acct_…). Optional when an API key is sent: its account is used." + }, + "event" => %{ + "type" => "string", + "description" => + "What happened, e.g. page_view, tool_called, run_completed, signup, purchase, error. " <> + "page_view with a path is recorded as a real pageview." + }, + "project" => %{ + "type" => "string", + "description" => + "Which website, app or AI tool this is. Keeps projects separate in reports." + }, + "user" => %{ + "type" => "string", + "description" => + "Your own stable ID for who the event is about — an account, customer or mailbox ID. " <> + "Prefer an opaque ID to an email address." + }, + "user_identifiers" => %{ + "type" => "object", + "additionalProperties" => %{"type" => ["string", "number", "boolean"]}, + "description" => + "Other identifiers for that user, e.g. {\"domain\": \"acme.com\", \"plan\": \"pro\"}. Shown beside the user." + }, + "attributes" => %{ + "type" => "object", + "additionalProperties" => %{"type" => ["string", "number", "boolean"]}, + "description" => + "Any dimensions or quantities, e.g. {\"tool\": \"search\", \"latency_ms\": 420, \"usd\": 19.99}. " <> + "Numbers are summed and graphed. Up to 20." + }, + "session_id" => %{ + "type" => "string", + "description" => + "Groups events into one session: a run, conversation or visit. Without it, events are grouped per user over 30 minutes." + }, + "path" => %{ + "type" => "string", + "description" => "A page, screen or step, e.g. /checkout." + }, + "title" => %{ + "type" => "string", + "description" => "A human-readable name for the path." + }, + "city" => %{"type" => "string", "description" => "The user's city."}, + "county" => %{"type" => "string", "description" => "The user's county."}, + "state" => %{"type" => "string", "description" => "The user's state or province."}, + "country" => %{ + "type" => "string", + "description" => "The user's country, as a name or ISO code." + }, + "channel" => %{ + "type" => "string", + "default" => "ai", + "description" => "ai for an AI tool or agent, web for a website." + }, + "agent_name" => %{ + "type" => "string", + "description" => "The AI or tool doing the reporting, e.g. Claude Code." + }, + "referrer" => %{"type" => "string", "description" => "Where this came from."} + }, + "required" => ["event"] + }, + "annotations" => %{ + "readOnlyHint" => false, + "destructiveHint" => false, + "idempotentHint" => false, + "openWorldHint" => false + } + }, + %{ + "name" => "get_integration_guide", + "title" => "Get the tracking code and API", + "access" => :open, + "description" => + "How to add analytics to a project permanently: the one-line website tracking script " <> + "(a free Google Analytics alternative), the event API URL " <> + "for AI agents, CLIs and backends, and the config to connect this MCP server to Claude, " <> + "Cursor, VS Code or other MCP clients.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "account_id" => %{ + "type" => "string", + "description" => "Fill the account ID into the snippets." + }, + "project" => %{ + "type" => "string", + "description" => "Fill the project name into the snippets." + }, + "kind" => %{ + "type" => "string", + "enum" => ["website", "ai_tool", "both"], + "default" => "both", + "description" => "A website, an AI tool or backend, or both." + } + } + }, + "annotations" => @read + }, + read_tool( + "get_account", + "Account, projects and domains", + "Which account the API key belongs to, and the projects, website domains and traffic " <> + "channels reporting into it — the values the other reports' project and domain filters take.", + %{} + ), + read_tool( + "get_analytics_overview", + "Traffic overview", + "Website and app traffic summary: sessions, unique visitors, identified users, pageviews, " <> + "bounce rate, average engagement time, scroll depth, clicks and outbound links, with the " <> + "top pages, top referrers, channels, devices and countries. The best first call for " <> + "\"how is my site doing?\".", + %{} + ), + read_tool( + "get_traffic_timeseries", + "Visits over time", + "Sessions and pageviews bucketed over the time range, for spotting trends, spikes and drops in traffic.", + %{ + "buckets" => %{ + "type" => "integer", + "minimum" => 4, + "maximum" => 96, + "default" => 24, + "description" => "How many time buckets to split the range into." + } + } + ), + read_tool( + "get_top_pages", + "Top pages", + "Most viewed pages with views, sessions, average time on page, scroll depth, clicks, entrances " <> + "and exit rate, plus the top landing pages and exit pages.", + %{ + "limit" => limit_schema(25), + "group_by" => %{ + "type" => "string", + "enum" => ["path", "title"], + "default" => "path", + "description" => "Group by URL path or by page title." + } + } + ), + read_tool( + "get_traffic_sources", + "Traffic sources and audience", + "Break sessions down by one dimension: referrer, UTM source, medium or campaign, browser, " <> + "operating system, device type, language, country, region, city or channel. Answers " <> + "\"where does my traffic come from?\" and \"who are my visitors?\".", + %{ + "dimension" => %{ + "type" => "string", + "enum" => Map.keys(dimensions()) |> Enum.sort(), + "default" => "referrer", + "description" => "What to break sessions down by." + }, + "limit" => limit_schema(15) + } + ), + read_tool( + "get_events", + "Custom events", + "Custom events by count, sessions and first and last seen — tool calls, signups, purchases, " <> + "errors, conversions. Name one `event` to also get its attributes' commonest values and " <> + "its volume over time.", + %{ + "event" => %{"type" => "string", "description" => "One event name to drill into."}, + "limit" => limit_schema(30) + } + ), + read_tool( + "get_metrics", + "Metrics: revenue, tokens, counts", + "Numbers sent as event attributes, summed: revenue, sats, tokens, PRs, latency — each with " <> + "total, count, average, minimum and maximum. Name a `key` to chart it per hour or per day.", + %{ + "key" => %{ + "type" => "string", + "description" => "One metric to chart over time, e.g. usd." + }, + "granularity" => %{ + "type" => "string", + "enum" => ["hour", "day"], + "default" => "day", + "description" => "Bucket size for the chart." + } + } + ), + read_tool( + "list_users", + "Identified users", + "Every identified user — events sent with `user` — with sessions, events, pageviews, " <> + "projects, locations, first and last seen, and their other identifiers. For user " <> + "analytics, customer activity and finding the most engaged accounts.", + %{ + "sort" => %{ + "type" => "string", + "enum" => ["recent", "sessions", "events"], + "default" => "recent", + "description" => "Most recently active, most sessions, or most events first." + }, + "limit" => limit_schema(50) + } + ), + read_tool( + "get_user_activity", + "One user's activity", + "Everything one identified user did: a profile with their identifiers and totals, a " <> + "timeline of their events with attributes, what they did most, and the metrics they " <> + "generated.", + %{"limit" => limit_schema(50)}, + ["user"] + ), + read_tool( + "get_live_visitors", + "Live visitors right now", + "Real-time analytics: who is on the website or using the AI tool right now — active " <> + "sessions and visitors in the last 30 seconds and 30 minutes, with the page or event " <> + "each is on. Ignores range.", + %{} + ), + read_tool( + "get_page_flow", + "Page flow and user journeys", + "How visitors move through a site or app: the busiest page-to-page transitions and the " <> + "most common three-step journeys, for funnel and navigation analysis.", + %{"limit" => limit_schema(20)} + ), + read_tool( + "get_ai_crawler_traffic", + "AI crawler and bot traffic", + "Which AI crawlers and bots visit the website — GPTBot, ClaudeBot, PerplexityBot, " <> + "Google-Extended and others — how often, and which pages they read. For AI search " <> + "visibility and LLM crawler monitoring.", + %{"limit" => limit_schema(25)} + ) + ] + end + + defp read_tool(name, title, description, extra, required \\ []) do + %{ + "name" => name, + "title" => title, + "access" => :key, + "description" => description <> " Needs the account's API key.", + "inputSchema" => + %{"type" => "object", "properties" => Map.merge(@report_filters, extra)} + |> then(fn schema -> + if required == [], do: schema, else: Map.put(schema, "required", required) + end), + "annotations" => @read + } + end + + defp limit_schema(default) do + %{ + "type" => "integer", + "minimum" => 1, + "maximum" => 200, + "default" => default, + "description" => "Most rows to return." + } + end + + defp dimensions do + %{ + "referrer" => :referrer_host, + "utm_source" => :utm_source, + "utm_medium" => :utm_medium, + "utm_campaign" => :utm_campaign, + "browser" => :browser, + "os" => :os, + "device" => :device_type, + "language" => :language, + "country" => :country, + "region" => :region, + "city" => :city, + "channel" => :channel + } + end + + # -- access -------------------------------------------------------------- + + defp with_account(%{auth: :valid, site: site}, fun) when not is_nil(site), do: fun.(site) + + defp with_account(%{auth: :invalid} = ctx, _fun) do + error( + "The API key in the Authorization header is not valid or has been revoked. Create a new " <> + "one on #{ctx.base_url}/getting-started, under MCP server." + ) + end + + defp with_account(ctx, _fun) do + error( + "Reading reports needs the account's API key, sent as `Authorization: Bearer #{ApiKeys.prefix()}…`. " <> + "Create one on #{ctx.base_url}/getting-started under MCP server, or use the api_key returned " <> + "by create_analytics_account. Tracking events does not need one." + ) + end + + # -- writing ------------------------------------------------------------- + + defp run("create_analytics_account", args, ctx) do + params = %{"project" => string(args["project"]), "email" => string(args["email"])} + + case AccountProvisioning.create(params, ctx.ip) do + {:ok, body, site} -> + data = + case ApiKeys.create(site, "Created through MCP") do + {:ok, token, _key} -> + Map.merge(body, %{ + api_key: token, + api_key_usage: + "Send as the header `Authorization: Bearer #{token}` to read this account's reports. " <> + "It is shown once: store it with the account ID." + }) + + _ -> + body + end + + ok(data, "Created account #{site.key}.") + + {:error, _reason, body} -> + error(body.message, body) + end + end + + defp run("track_event", args, ctx) do + account_id = string(args["account_id"]) || (ctx[:site] && ctx.site.key) + + cond do + is_nil(account_id) -> + error("track_event needs account_id (or an API key, whose account is used).") + + is_nil(string(args["event"])) -> + error("track_event needs an event name.") + + true -> + {params, ignored} = ping_params(args, account_id) + + case Ping.submit(params, ip: ctx.ip, headers: Map.get(ctx, :headers, [])) do + {:ok, _site} -> + ok( + %{ + recorded: true, + account_id: account_id, + event: params["event"], + ignored_attributes: ignored + }, + "Recorded #{params["event"]}." + ) + + # Without a key, an unknown account gets the same answer as a real + # one, like the HTTP API: this must not become a way to test whether + # an account ID exists. Holding that account's key proves ownership, + # and then a typo is worth pointing out. + :unknown_account -> + if ctx[:site] && ctx.site.key == account_id do + error("Account #{account_id} does not exist.") + else + ok( + %{ + accepted: true, + account_id: account_id, + event: params["event"], + ignored_attributes: ignored + }, + "Accepted #{params["event"]}. It is recorded if #{account_id} is a real account ID." + ) + end + end + end + end + + defp run("get_integration_guide", args, ctx) do + base = ctx.base_url + key = string(args["account_id"]) || (ctx[:site] && ctx.site.key) || "YOUR_ACCOUNT_ID" + project = string(args["project"]) || "my-project" + kind = if args["kind"] in ["website", "ai_tool"], do: args["kind"], else: "both" + + website = ~s|| + + ping = + "#{base}/api/ping?uid=#{key}&type=ai&project=#{URI.encode_www_form(project)}" <> + "&event=run_started&sid=SESSION_ID&user=USER_ID&c=CITY&s_p=STATE&n=COUNTRY" + + mcp = %{ + "url" => base <> "/mcp", + "claude_code" => + "claude mcp add --transport http seriouslysimpleanalytics #{base}/mcp " <> + "--header \"Authorization: Bearer YOUR_API_KEY\"", + "json_config" => %{ + "mcpServers" => %{ + "seriouslysimpleanalytics" => %{ + "type" => "http", + "url" => base <> "/mcp", + "headers" => %{"Authorization" => "Bearer YOUR_API_KEY"} + } + } + } + } + + data = + %{ + account_id: key, + docs: base <> "/llms.txt", + dashboard: base <> "/dashboard", + mcp_server: mcp + } + |> then(fn d -> + if kind in ["website", "both"], do: Map.put(d, :website_script_tag, website), else: d + end) + |> then(fn d -> + if kind in ["ai_tool", "both"], do: Map.put(d, :event_api_url, ping), else: d + end) + + guide = + [ + kind in ["website", "both"] && + "Website: put this tag in the shared layout, before . One tag covers every page.\n\n #{website}", + kind in ["ai_tool", "both"] && + "AI tool, CLI or backend: fetch this URL once per event (GET or POST, 204 response, never retry). " <> + "Reuse one sid per run, send user= for per-user analytics, and add any attribute as a parameter.\n\n #{ping}", + "Full contract, including what never to send: #{base}/llms.txt" + ] + |> Enum.filter(& &1) + |> Enum.join("\n\n") + + ok(data, guide) + end + + # -- reading ------------------------------------------------------------- + + defp run("get_account", args, ctx) do + f = filters(ctx.site, Map.put_new(args, "range", "all")) + + ok( + %{ + account_id: ctx.site.key, + name: ctx.site.name, + projects: Analytics.projects(f), + domains: Analytics.domains(f), + channels: Analytics.channels(f) + }, + "Account #{ctx.site.key} (#{ctx.site.name})." + ) + end + + defp run("get_analytics_overview", args, ctx) do + f = filters(ctx.site, args) + totals = Analytics.overview(f) + + ok( + %{ + range: f.range, + totals: Map.drop(totals, [:bounces]), + top_pages: + f + |> Analytics.pages(10) + |> Enum.map(&Map.take(&1, [:name, :views, :sessions, :dwell_ms, :exit_rate])), + top_referrers: Analytics.session_breakdown(f, :referrer_host, 10), + channels: Analytics.channels(f), + devices: Analytics.session_breakdown(f, :device_type, 5), + countries: Analytics.session_breakdown(f, :country, 10) + }, + "#{totals.sessions} sessions, #{totals.visitors} visitors and #{totals.pageviews} pageviews (#{f.range})." + ) + end + + defp run("get_traffic_timeseries", args, ctx) do + f = filters(ctx.site, args) + buckets = integer(args["buckets"], 24, 4, 96) + ok(%{range: f.range, series: Analytics.timeseries(f, buckets)}, "Sessions over #{f.range}.") + end + + defp run("get_top_pages", args, ctx) do + f = filters(ctx.site, args) + limit = integer(args["limit"], 25, 1, 200) + + ok( + %{ + range: f.range, + group_by: f.group_by, + pages: Analytics.pages(f, limit), + landing_pages: Analytics.entries(f), + exit_pages: Analytics.exits(f) + }, + "Top pages (#{f.range})." + ) + end + + defp run("get_traffic_sources", args, ctx) do + f = filters(ctx.site, args) + + dimension = + if Map.has_key?(dimensions(), args["dimension"]), do: args["dimension"], else: "referrer" + + limit = integer(args["limit"], 15, 1, 200) + + ok( + %{ + range: f.range, + dimension: dimension, + rows: Analytics.session_breakdown(f, Map.fetch!(dimensions(), dimension), limit) + }, + "Sessions by #{dimension} (#{f.range})." + ) + end + + defp run("get_events", args, ctx) do + f = filters(ctx.site, args) + limit = integer(args["limit"], 30, 1, 200) + name = string(args["event"]) + + data = %{range: f.range, events: Analytics.events(f, limit)} + + data = + if name do + Map.merge(data, %{ + event: name, + attributes: Analytics.event_attributes(f, name), + over_time: Analytics.event_timeseries(f, name) + }) + else + data + end + + ok(data, "Events (#{f.range}).") + end + + defp run("get_metrics", args, ctx) do + f = filters(ctx.site, args) + granularity = if args["granularity"] == "hour", do: :hour, else: :day + key = string(args["key"]) + metrics = Analytics.metrics(f) + + data = %{range: f.range, metrics: metrics} + + data = + if key do + Map.merge(data, %{ + key: key, + granularity: granularity, + series: Analytics.metric_series(f, key, granularity: granularity) + }) + else + data + end + + ok(data, "#{length(metrics)} numeric metric(s) (#{f.range}).") + end + + defp run("list_users", args, ctx) do + f = filters(ctx.site, args) + sort = Enum.find(Analytics.user_sorts(), :recent, &(Atom.to_string(&1) == args["sort"])) + users = Analytics.users(f, integer(args["limit"], 50, 1, 200), sort) + + ok( + %{range: f.range, sort: sort, users: users}, + "#{length(users)} identified user(s) (#{f.range})." + ) + end + + defp run("get_user_activity", args, ctx) do + case string(args["user"]) do + nil -> + error("get_user_activity needs a user ID. list_users shows them.") + + user -> + f = filters(ctx.site, Map.put(args, "user", user)) + + case Analytics.user_profile(f) do + nil -> + error( + "No activity from user #{user} in #{f.range}. Try range \"all\", or list_users." + ) + + profile -> + timeline = + f + |> Analytics.recent_events(integer(args["limit"], 50, 1, 200)) + |> Enum.map(&Map.take(&1, [:at, :name, :path, :attrs, :project, :session_token])) + + ok( + %{ + range: f.range, + profile: profile, + timeline: timeline, + top_events: Analytics.events(f, 15), + metrics: Analytics.metrics(f) + }, + "#{user}: #{profile.sessions} session(s), #{profile.events} event(s) (#{f.range})." + ) + end + end + end + + defp run("get_live_visitors", args, ctx) do + live = ctx.site |> filters(args) |> Analytics.active_now() + + sessions = + Enum.map(live.sessions_list, fn s -> + Map.take(s, [ + :started_at, + :last_seen_at, + :user_id, + :project, + :channel, + :entry_path, + :exit_path, + :referrer_host, + :pageview_count, + :dwell_ms, + :last_event, + :events, + :city, + :country, + :browser, + :device_type + ]) + end) + + ok( + live |> Map.drop([:sessions_list]) |> Map.put(:sessions, sessions), + "#{live.live_sessions} active in the last 30 seconds, #{live.sessions} in the last 30 minutes." + ) + end + + defp run("get_page_flow", args, ctx) do + f = filters(ctx.site, args) + limit = integer(args["limit"], 20, 1, 200) + + ok( + %{ + range: f.range, + transitions: Analytics.flow(f, limit), + journeys: Analytics.journeys(f, min(limit, 25)) + }, + "Page flow (#{f.range})." + ) + end + + defp run("get_ai_crawler_traffic", args, ctx) do + f = filters(ctx.site, args) + limit = integer(args["limit"], 25, 1, 200) + + ok( + %{ + range: f.range, + overview: Analytics.crawler_overview(f), + crawlers: Analytics.crawlers_by_name(f, limit), + kinds: Analytics.crawlers_by_kind(f), + pages: Analytics.crawler_pages(f, limit) + }, + "Crawler traffic (#{f.range})." + ) + end + + # -- helpers ------------------------------------------------------------- + + defp filters(site, args) do + Analytics.filters(site.id, %{ + range: if(args["range"] in @ranges, do: args["range"], else: "7d"), + project: string(args["project"]), + host: string(args["domain"]), + user: string(args["user"]), + exclude_crawlers: args["include_bots"] != true, + group_by: if(args["group_by"] == "title", do: :title, else: :path) + }) + end + + # The tool's readable argument names, translated into the ping API's own, so + # the event is recorded by exactly the code a GET /api/ping would reach. + defp ping_params(args, account_id) do + named = %{ + "uid" => account_id, + "event" => string(args["event"]), + "project" => string(args["project"]), + "user" => scalar(args["user"]), + "sid" => string(args["session_id"]), + "path" => string(args["path"]), + "title" => string(args["title"]), + "city" => string(args["city"]), + "county" => string(args["county"]), + "state" => string(args["state"]), + "country" => string(args["country"]), + "type" => string(args["channel"]), + "name" => string(args["agent_name"]), + "ref" => string(args["referrer"]) + } + + identifiers = + for {key, value} <- object(args["user_identifiers"]), scalar(value), into: %{} do + {"user_" <> key, scalar(value)} + end + + # An attribute named like a reserved parameter would silently change what + # the event means — `name` would rename the reporting tool — so those are + # dropped and reported back rather than passed through. + {attributes, ignored} = + object(args["attributes"]) + |> Enum.filter(fn {_key, value} -> scalar(value) end) + |> Enum.split_with(fn {key, _value} -> key not in Ping.reserved() end) + + params = + named + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + |> Map.merge(Map.new(attributes, fn {key, value} -> {key, scalar(value)} end)) + |> Map.merge(identifiers) + + {params, Enum.map(ignored, &elem(&1, 0))} + end + + defp object(value) when is_map(value), do: value + defp object(_), do: %{} + + defp scalar(value) when is_binary(value), do: string(value) + + defp scalar(value) when is_integer(value) or is_float(value) or is_boolean(value), + do: to_string(value) + + defp scalar(_), do: nil + + defp string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp string(_), do: nil + + defp integer(value, _default, low, high) when is_integer(value), + do: value |> max(low) |> min(high) + + defp integer(_value, default, _low, _high), do: default + + defp ok(data, summary) do + data = jsonable(data) + + {:ok, + %{ + "content" => [ + %{"type" => "text", "text" => summary <> "\n\n" <> Jason.encode!(data, pretty: true)} + ], + "structuredContent" => data + }} + end + + # A failed tool call is a result the model reads, not a protocol error: it + # can fix the arguments and try again. + defp error(message, data \\ nil) do + result = %{"content" => [%{"type" => "text", "text" => message}], "isError" => true} + {:ok, if(data, do: Map.put(result, "structuredContent", jsonable(data)), else: result)} + end + + @doc false + def jsonable(%DateTime{} = value), do: DateTime.to_iso8601(value) + def jsonable(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) + def jsonable(%Date{} = value), do: Date.to_iso8601(value) + def jsonable(%Decimal{} = value), do: value |> Decimal.round(4) |> Decimal.to_float() + + def jsonable(%{__struct__: _} = struct) do + struct + |> Map.from_struct() + |> Map.drop([:__meta__]) + |> Map.reject(fn {_key, value} -> match?(%Ecto.Association.NotLoaded{}, value) end) + |> jsonable() + end + + def jsonable(map) when is_map(map), + do: Map.new(map, fn {key, value} -> {to_string(key), jsonable(value)} end) + + def jsonable(list) when is_list(list), do: Enum.map(list, &jsonable/1) + def jsonable(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> jsonable() + + def jsonable(value) when is_atom(value) and not is_boolean(value) and not is_nil(value), + do: Atom.to_string(value) + + def jsonable(value), do: value +end diff --git a/lib/web_analytics_web/plugs/cors.ex b/lib/web_analytics_web/plugs/cors.ex index 0ebae60..ed36893 100644 --- a/lib/web_analytics_web/plugs/cors.ex +++ b/lib/web_analytics_web/plugs/cors.ex @@ -12,18 +12,28 @@ defmodule WebAnalyticsWeb.Plugs.Cors do @behaviour Plug @impl true - def init(opts), do: opts + def init(opts) do + %{ + methods: Keyword.get(opts, :methods, "POST, GET, OPTIONS"), + headers: Keyword.get(opts, :headers, "content-type"), + expose: Keyword.get(opts, :expose) + } + end @impl true - def call(conn, _opts) do + def call(conn, opts) do conn |> put_resp_header("access-control-allow-origin", origin(conn)) - |> put_resp_header("access-control-allow-methods", "POST, GET, OPTIONS") - |> put_resp_header("access-control-allow-headers", "content-type") + |> put_resp_header("access-control-allow-methods", opts.methods) + |> put_resp_header("access-control-allow-headers", opts.headers) |> put_resp_header("access-control-max-age", "86400") + |> expose(opts.expose) |> handle_preflight() end + defp expose(conn, nil), do: conn + defp expose(conn, headers), do: put_resp_header(conn, "access-control-expose-headers", headers) + defp origin(conn) do case get_req_header(conn, "origin") do [origin | _] when origin != "" -> origin diff --git a/lib/web_analytics_web/plugs/crawler_report.ex b/lib/web_analytics_web/plugs/crawler_report.ex index b8a4dbd..93e3ba9 100644 --- a/lib/web_analytics_web/plugs/crawler_report.ex +++ b/lib/web_analytics_web/plugs/crawler_report.ex @@ -20,6 +20,7 @@ defmodule WebAnalyticsWeb.Plugs.CrawlerReport do alias WebAnalytics.Ingest alias WebAnalytics.Ingest.Crawler alias WebAnalytics.Sites + alias WebAnalyticsWeb.ClientIP @behaviour Plug @@ -70,7 +71,7 @@ defmodule WebAnalyticsWeb.Plugs.CrawlerReport do defp report(conn, site, user_agent, verdict) do now = DateTime.utc_now() - ip = client_ip(conn) + ip = ClientIP.get(conn) ip_hash = Ingest.hash_ip(ip, site) unix_ms = DateTime.to_unix(now, :millisecond) @@ -124,15 +125,4 @@ defmodule WebAnalyticsWeb.Plugs.CrawlerReport do [] -> nil end end - - defp client_ip(conn) do - if Application.get_env(:web_analytics, :trust_proxy_headers, false) do - case get_req_header(conn, "x-forwarded-for") do - [value | _] -> value |> String.split(",") |> List.first() |> String.trim() - [] -> conn.remote_ip |> :inet.ntoa() |> to_string() - end - else - conn.remote_ip |> :inet.ntoa() |> to_string() - end - end end diff --git a/lib/web_analytics_web/router.ex b/lib/web_analytics_web/router.ex index b77facf..b398848 100644 --- a/lib/web_analytics_web/router.ex +++ b/lib/web_analytics_web/router.ex @@ -22,6 +22,31 @@ defmodule WebAnalyticsWeb.Router do plug WebAnalyticsWeb.Plugs.Cors end + # The MCP endpoint. Cross-origin and cookie-free like the event API, so a + # browser-based MCP client works; reading reports needs an API key sent + # explicitly in a header, which no page can borrow from a visitor. + pipeline :mcp do + plug WebAnalyticsWeb.Plugs.Cors, + methods: "POST, GET, DELETE, OPTIONS", + headers: + "content-type, accept, authorization, x-api-key, mcp-protocol-version, mcp-method, " <> + "mcp-name, mcp-session-id, last-event-id", + expose: "mcp-protocol-version, www-authenticate" + end + + scope "/", WebAnalyticsWeb do + pipe_through :mcp + + post "/mcp", MCPController, :handle + get "/mcp", MCPController, :stream + delete "/mcp", MCPController, :stream + match :options, "/mcp", MCPController, :options + + # Proves to the MCP Registry that whoever publishes + # com.seriouslysimpleanalytics/* controls this domain. + get "/.well-known/mcp-registry-auth", MCPController, :registry_auth + end + scope "/api/v1", WebAnalyticsWeb do pipe_through :public_api @@ -59,6 +84,7 @@ defmodule WebAnalyticsWeb.Router do get "/", LandingController, :home get "/AI-Analytics-llms-txt", LandingController, :ai + get "/analytics-mcp-server", LandingController, :mcp # One page per AI crawler provider. Generated from the same registry that # holds the content, so a route can never point at a page that isn't written. diff --git a/priv/repo/migrations/20260914120000_create_api_keys.exs b/priv/repo/migrations/20260914120000_create_api_keys.exs new file mode 100644 index 0000000..2628821 --- /dev/null +++ b/priv/repo/migrations/20260914120000_create_api_keys.exs @@ -0,0 +1,23 @@ +defmodule WebAnalytics.Repo.Migrations.CreateApiKeys do + use Ecto.Migration + + def change do + create table(:api_keys) do + add :site_id, references(:sites, on_delete: :delete_all), null: false + add :name, :string, null: false + # The first characters of the key, kept so a list of keys can tell them + # apart. Never enough to use. + add :prefix, :string, null: false + # Only the hash. The key itself is shown once, when it is created, and is + # not recoverable from anything stored here. + add :token_hash, :binary, null: false + add :last_used_at, :utc_datetime_usec + add :revoked_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:api_keys, [:token_hash]) + create index(:api_keys, [:site_id]) + end +end diff --git a/test/web_analytics_web/controllers/mcp_controller_test.exs b/test/web_analytics_web/controllers/mcp_controller_test.exs new file mode 100644 index 0000000..1b66d25 --- /dev/null +++ b/test/web_analytics_web/controllers/mcp_controller_test.exs @@ -0,0 +1,443 @@ +defmodule WebAnalyticsWeb.MCPControllerTest do + # Not async: tracking lends the global ingest buffer this test's connection, + # and account creation shares the global rate limiter. + use WebAnalyticsWeb.ConnCase, async: false + + import Ecto.Query + import WebAnalytics.Fixtures + + alias Ecto.Adapters.SQL.Sandbox + alias WebAnalytics.ApiKeys + alias WebAnalytics.Ingest + alias WebAnalytics.Ingest.Collector + alias WebAnalytics.RateLimiter + alias WebAnalytics.Repo + alias WebAnalytics.Tracking.Session + alias WebAnalyticsWeb.MCP.Server + alias WebAnalyticsWeb.MCP.Tools + + @modern "2026-07-28" + + setup do + Sandbox.allow(Repo, self(), Process.whereis(Collector)) + Collector.reset() + RateLimiter.reset() + + site = site_fixture(%{key: "acct_mcp", name: "MCP Site"}) + {:ok, token, _key} = ApiKeys.create(site, "test") + %{site: site, token: token} + end + + defp rpc(conn, method, params \\ %{}, opts \\ []) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + conn = + Enum.reduce(Keyword.get(opts, :headers, []), conn, fn {name, value}, conn -> + put_req_header(conn, name, value) + end) + + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("accept", "application/json, text/event-stream") + |> post(~p"/mcp", Jason.encode!(Keyword.get(opts, :body, body))) + end + + defp modern(params \\ %{}) do + Map.put(params, "_meta", %{ + "io.modelcontextprotocol/protocolVersion" => @modern, + "io.modelcontextprotocol/clientCapabilities" => %{} + }) + end + + defp call_tool(conn, name, arguments, opts \\ []) do + conn + |> rpc("tools/call", %{"name" => name, "arguments" => arguments}, opts) + |> json_response(200) + |> Map.fetch!("result") + end + + defp bearer(token), do: [headers: [{"authorization", "Bearer " <> token}]] + + describe "the handshake era" do + test "initialize negotiates the client's version and describes the server", %{conn: conn} do + result = + conn + |> rpc("initialize", %{ + "protocolVersion" => "2025-06-18", + "capabilities" => %{}, + "clientInfo" => %{"name" => "test", "version" => "1"} + }) + |> json_response(200) + |> Map.fetch!("result") + + assert result["protocolVersion"] == "2025-06-18" + assert result["serverInfo"]["name"] == "seriouslysimpleanalytics" + assert result["serverInfo"]["version"] == Server.version() + assert Map.keys(result["capabilities"]) |> Enum.sort() == ["prompts", "resources", "tools"] + assert result["instructions"] =~ "API key" + # resultType belongs to the stateless era only. + refute Map.has_key?(result, "resultType") + end + + test "an unknown version is answered with the newest handshake version", %{conn: conn} do + result = + conn + |> rpc("initialize", %{"protocolVersion" => "1999-01-01", "capabilities" => %{}}) + |> json_response(200) + + assert result["result"]["protocolVersion"] == "2025-11-25" + end + + test "notifications are accepted with an empty 202", %{conn: conn} do + conn = + rpc(conn, "notifications/initialized", %{}, + body: %{"jsonrpc" => "2.0", "method" => "notifications/initialized"} + ) + + assert response(conn, 202) == "" + end + + test "ping answers, and an unknown method is a JSON-RPC error", %{conn: conn} do + assert conn |> rpc("ping") |> json_response(200) |> Map.fetch!("result") == %{} + + error = build_conn() |> rpc("nope/nope") |> json_response(200) + assert error["error"]["code"] == -32601 + end + + test "a batch gets one answer per request and none per notification", %{conn: conn} do + replies = + conn + |> rpc("ignored", %{}, + body: [ + %{"jsonrpc" => "2.0", "id" => 1, "method" => "ping"}, + %{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, + %{"jsonrpc" => "2.0", "id" => 2, "method" => "tools/list"} + ] + ) + |> json_response(200) + + assert Enum.map(replies, & &1["id"]) == [1, 2] + end + + test "a garbage protocol header is refused", %{conn: conn} do + conn = rpc(conn, "tools/list", %{}, headers: [{"mcp-protocol-version", "banana"}]) + assert json_response(conn, 400)["error"]["code"] == -32600 + end + end + + describe "the stateless era (2026-07-28)" do + test "server/discover lists versions, capabilities and identity", %{conn: conn} do + result = + conn + |> rpc("server/discover", modern(), + headers: [{"mcp-protocol-version", @modern}, {"mcp-method", "server/discover"}] + ) + |> json_response(200) + |> Map.fetch!("result") + + assert result["resultType"] == "complete" + assert @modern in result["supportedVersions"] + assert "2025-06-18" in result["supportedVersions"] + assert result["cacheScope"] == "public" + assert result["ttlMs"] > 0 + + assert result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == + "seriouslysimpleanalytics" + end + + test "lists are cacheable and complete", %{conn: conn} do + result = conn |> rpc("tools/list", modern()) |> json_response(200) |> Map.fetch!("result") + + assert result["resultType"] == "complete" + assert result["cacheScope"] == "public" + assert length(result["tools"]) == length(Tools.definitions()) + end + + test "an unsupported version is refused with the versions that are", %{conn: conn} do + params = + put_in(modern(), ["_meta", "io.modelcontextprotocol/protocolVersion"], "2099-01-01") + + body = conn |> rpc("tools/list", params) |> json_response(400) + + assert body["error"]["code"] == -32022 + assert body["error"]["data"]["requested"] == "2099-01-01" + assert @modern in body["error"]["data"]["supported"] + end + + test "headers that disagree with the body are refused", %{conn: conn} do + body = + conn + |> rpc("tools/list", modern(), headers: [{"mcp-method", "tools/call"}]) + |> json_response(400) + + assert body["error"]["code"] == -32020 + + body = + build_conn() + |> rpc("tools/list", modern(), headers: [{"mcp-protocol-version", "2025-06-18"}]) + |> json_response(400) + + assert body["error"]["code"] == -32020 + end + + test "an unknown method is a 404, and initialize no longer exists", %{conn: conn} do + assert conn + |> rpc("initialize", modern()) + |> json_response(404) + |> get_in(["error", "code"]) == + -32601 + end + + test "tool calls work the same", %{conn: conn, token: token} do + result = + conn + |> rpc( + "tools/call", + modern(%{"name" => "get_account", "arguments" => %{}}), + bearer(token) + ) + |> json_response(200) + |> Map.fetch!("result") + + assert result["resultType"] == "complete" + assert result["structuredContent"]["account_id"] == "acct_mcp" + end + end + + describe "tools" do + test "every tool has a name, title, description, schema and annotations" do + tools = Tools.definitions() + names = Enum.map(tools, & &1["name"]) + + assert names == Enum.uniq(names) + + for tool <- tools do + assert tool["name"] =~ ~r/^[a-z_]+$/ + assert is_binary(tool["title"]) + assert String.length(tool["description"]) > 60 + assert tool["inputSchema"]["type"] == "object" + assert is_boolean(tool["annotations"]["readOnlyHint"]) + end + end + + test "track_event records through the same path as the event API", %{conn: conn, site: site} do + result = + call_tool(conn, "track_event", %{ + "account_id" => site.key, + "event" => "pr_merged", + "project" => "marketplace", + "session_id" => "mcp-run-1", + "user" => "seller_9", + "user_identifiers" => %{"wallet" => "wal_3f"}, + "attributes" => %{"sats" => 1500, "repo" => "x", "name" => "would rename the tool"}, + "country" => "US" + }) + + Collector.flush_sync() + + refute result["isError"] + assert result["structuredContent"]["ignored_attributes"] == ["name"] + + session = Repo.one(from s in Session, where: s.token == "mcp-run-1", preload: :events) + assert session.project == "marketplace" + assert session.user_id == "seller_9" + assert session.user_traits == %{"wallet" => "wal_3f"} + assert session.country_code == "US" + assert [event] = session.events + assert event.name == "pr_merged" + assert event.data_attrs == %{"sats" => "1500", "repo" => "x", "user_wallet" => "wal_3f"} + end + + test "track_event does not reveal whether an account exists", %{conn: conn} do + result = call_tool(conn, "track_event", %{"account_id" => "acct_nope", "event" => "x"}) + + refute result["isError"] + assert result["structuredContent"]["accepted"] == true + end + + test "reading needs a key, and says how to get one", %{conn: conn} do + result = call_tool(conn, "get_analytics_overview", %{}) + + assert result["isError"] == true + assert hd(result["content"])["text"] =~ "getting-started" + + result = + call_tool(build_conn(), "get_analytics_overview", %{}, bearer("ssa_not-a-real-key")) + + assert result["isError"] == true + assert hd(result["content"])["text"] =~ "not valid" + end + + test "a key reads its own account and nobody else's", %{conn: conn, site: site, token: token} do + other = site_fixture(%{key: "acct_other"}) + + for {s, path} <- [{site, "/mine"}, {other, "/theirs"}] do + {:ok, _} = + Ingest.submit_sync(s, payload(s, [init_event(), pageview_event(1, path)]), + received_at: DateTime.utc_now() + ) + end + + result = call_tool(conn, "get_top_pages", %{"range" => "24h"}, bearer(token)) + + refute result["isError"] + paths = Enum.map(result["structuredContent"]["pages"], & &1["name"]) + assert "/mine" in paths + refute "/theirs" in paths + end + + test "every reading tool answers with JSON on an account with data", %{ + conn: conn, + site: site, + token: token + } do + {:ok, _} = + Ingest.submit_sync( + site, + payload(site, [ + init_event(), + pageview_event(1, "/", %{"title" => "Home"}), + tick_event(1, %{"d" => 30_000, "am" => 25_000, "sp" => 75}), + click_event(%{"id" => "cta"}), + pageview_event(2, "/pricing", %{"title" => "Pricing", "fp" => "/", "ft" => "Home"}), + %{ + "n" => "event", + "t" => 1_000_000, + "name" => "purchase", + "data" => %{"usd" => "19.99"} + } + ]), + received_at: DateTime.utc_now(), + user_id: "acct_42" + ) + + reading = + Enum.reject( + Tools.definitions(), + &(&1["name"] in ~w(create_analytics_account track_event)) + ) + + for %{"name" => name} <- reading do + args = + case name do + "get_user_activity" -> %{"user" => "acct_42", "range" => "all"} + "get_events" -> %{"event" => "purchase"} + "get_metrics" -> %{"key" => "usd"} + _ -> %{} + end + + result = call_tool(build_conn(), name, args, bearer(token)) + refute result["isError"], "#{name} failed: #{inspect(result)}" + assert is_map(result["structuredContent"]), name + end + + activity = + call_tool( + conn, + "get_user_activity", + %{"user" => "acct_42", "range" => "all"}, + bearer(token) + ) + + assert [%{"key" => "usd", "sum" => 19.99}] = activity["structuredContent"]["metrics"] + end + + test "create_analytics_account returns an account ID and a working API key", %{conn: conn} do + result = call_tool(conn, "create_analytics_account", %{"project" => "new-agent"}) + + refute result["isError"] + data = result["structuredContent"] + assert data["uid"] =~ ~r/^acct_/ + assert data["claim_url"] =~ "/users/log-in/" + assert {:ok, site} = ApiKeys.authenticate(data["api_key"]) + assert site.key == data["uid"] + end + + test "get_integration_guide fills in the account", %{conn: conn} do + result = + call_tool(conn, "get_integration_guide", %{ + "account_id" => "acct_mcp", + "kind" => "website" + }) + + assert result["structuredContent"]["website_script_tag"] =~ ~s(data-site="acct_mcp") + refute Map.has_key?(result["structuredContent"], "event_api_url") + end + + test "an unknown tool is invalid params", %{conn: conn} do + body = + conn |> rpc("tools/call", %{"name" => "nope", "arguments" => %{}}) |> json_response(200) + + assert body["error"]["code"] == -32602 + end + end + + describe "resources and prompts" do + test "llms.txt is readable as a resource", %{conn: conn} do + [resource] = + conn |> rpc("resources/list") |> json_response(200) |> get_in(["result", "resources"]) + + [contents] = + build_conn() + |> rpc("resources/read", %{"uri" => resource["uri"]}) + |> json_response(200) + |> get_in(["result", "contents"]) + + assert contents["text"] =~ "## The whole API" + end + + test "prompts render", %{conn: conn} do + names = + conn + |> rpc("prompts/list") + |> json_response(200) + |> get_in(["result", "prompts"]) + |> Enum.map(& &1["name"]) + + assert names == ["add_analytics", "analytics_report"] + + [message] = + build_conn() + |> rpc("prompts/get", %{ + "name" => "add_analytics", + "arguments" => %{"account_id" => "acct_mcp"} + }) + |> json_response(200) + |> get_in(["result", "messages"]) + + assert message["content"]["text"] =~ "acct_mcp" + end + end + + describe "HTTP" do + test "a browser opening the endpoint is sent to the page about it", %{conn: conn} do + conn = conn |> put_req_header("accept", "text/html") |> get(~p"/mcp") + assert redirected_to(conn) == ~p"/analytics-mcp-server" + end + + test "there is no stream to open and no session to end", %{conn: conn} do + conn = conn |> put_req_header("accept", "text/event-stream") |> get(~p"/mcp") + assert conn.status == 405 + + assert build_conn() |> delete(~p"/mcp") |> Map.fetch!(:status) == 405 + end + + test "is reachable cross-origin with MCP headers", %{conn: conn} do + conn = + conn + |> put_req_header("origin", "https://inspector.example") + |> options(~p"/mcp") + + assert conn.status == 204 + assert get_resp_header(conn, "access-control-allow-origin") == ["https://inspector.example"] + [allowed] = get_resp_header(conn, "access-control-allow-headers") + assert allowed =~ "authorization" + assert allowed =~ "mcp-protocol-version" + end + + test "an empty body is a parse error", %{conn: conn} do + conn = conn |> put_req_header("content-type", "application/json") |> post(~p"/mcp", "") + assert json_response(conn, 400)["error"]["code"] == -32700 + end + end +end From f14dcd80b5fc7205a141a67664ee129dfab6145e Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 18 Sep 2026 12:24:47 -0700 Subject: [PATCH 2/3] MCP registry optimization: target real keywords, not a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched how the official MCP Registry, Smithery, Glama, PulseMCP and mcp.so actually surface servers. Verified fact: the official registry's search only substring-matches the `name` field, not description — so the domain-verified namespace itself is the highest-leverage lever available. - Add server.json at repo root under the domain-verified com.seriouslysimpleanalytics/analytics-mcp-server namespace (the domain already serves /.well-known/mcp-registry-auth), with a remotes entry for the Streamable HTTP endpoint and a 100-char keyword-front-loaded description (the registry's hard limit). - Fix a broken icon reference (icon-512.png never existed) to use the existing logo.svg, in both server.json and the MCP server's serverInfo. - Refresh GitHub topics: drop 9 redundant/low-signal tags, add mcp, mcp-server, model-context-protocol and 5 more — the repo previously had none of the three highest-signal tags in this space (~29k repos use mcp-server alone). - Tighten the MCP landing page hero, page title and meta description, and two tool descriptions, around the researched keyword set. - Add a README section introducing the MCP server. Human-required next steps (need live domain + personal accounts, can't be scripted from here): run `mcp-publisher login http` against the deployed domain to generate the /.well-known/mcp-registry-auth proof and publish server.json; then claim/submit on Smithery, Glama and mcp.so. Pages affected: - [SeriouslySimpleAnalytics](https://seriouslysimpleanalytics.com/) -- free, cookieless web analytics with one script tag. - [Analytics MCP Server](https://seriouslysimpleanalytics.com/analytics-mcp-server) -- the page this work targets, now with tightened keyword copy. - [Getting Started](https://seriouslysimpleanalytics.com/getting-started) -- where an MCP client's API key is created. - [AI Analytics (llms.txt)](https://seriouslysimpleanalytics.com/AI-Analytics-llms-txt) -- usage analytics for AI agents, MCP servers and CLIs. Closes #1 Co-Authored-By: Claude Sonnet 5 --- README.md | 26 ++++++++++++++++ .../controllers/landing_controller.ex | 6 ++-- .../controllers/landing_html/mcp.html.heex | 16 +++++----- lib/web_analytics_web/mcp/server.ex | 6 ++-- lib/web_analytics_web/mcp/tools.ex | 15 ++++----- server.json | 31 +++++++++++++++++++ 6 files changed, 79 insertions(+), 21 deletions(-) create mode 100644 server.json diff --git a/README.md b/README.md index c95d4b8..a36444d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,32 @@ separately. No new key, no new setup. Pageviews, engaged time, scroll depth, clicks, outbound clicks, forms and page-to-page flow — captured automatically, nothing to tag. +## MCP server for AI agents + +SeriouslySimpleAnalytics also ships as a remote +[MCP server](https://SeriouslySimpleAnalytics.com/analytics-mcp-server) +(Streamable HTTP at `/mcp`), so Claude, Cursor, VS Code and any other +MCP-speaking assistant can create an account, track events, and read +analytics reports without leaving the chat. 27 tools cover the whole +surface: `create_analytics_account`, `track_event`, `get_integration_guide`, +traffic and page reports (`get_analytics_overview`, `get_traffic_timeseries`, +`get_top_pages`, `get_traffic_sources`, `get_page_flow`), custom events and +metrics (`get_events`, `get_metrics` — revenue, tokens, sats, whatever you +count), per-user analytics (`list_users`, `get_user_activity`, +`get_live_visitors`), and AI crawler traffic (`get_ai_crawler_traffic` — +GPTBot, ClaudeBot, PerplexityBot, named and held out of your human numbers). + +Point your MCP client at: + +``` +https://SeriouslySimpleAnalytics.com/mcp +``` + +Same free account, same data as the dashboard and the ping API above — the +MCP server is just another way in. Tracking needs no key; reading reports +needs the account's API key, created on +[Getting started](https://SeriouslySimpleAnalytics.com/getting-started). + ## What you'll see | | | diff --git a/lib/web_analytics_web/controllers/landing_controller.ex b/lib/web_analytics_web/controllers/landing_controller.ex index afc04c5..4055c50 100644 --- a/lib/web_analytics_web/controllers/landing_controller.ex +++ b/lib/web_analytics_web/controllers/landing_controller.ex @@ -42,11 +42,11 @@ defmodule WebAnalyticsWeb.LandingController do def mcp(conn, _params) do conn - |> assign(:page_title, "Analytics MCP Server — free web & AI agent analytics") + |> assign(:page_title, "Free MCP Server for AI Agent Analytics — SeriouslySimpleAnalytics") |> assign( :page_description, - "A free remote MCP server for web analytics and AI agent analytics. Track events, " <> - "pageviews, users and revenue, and ask Claude, Cursor, VS Code or ChatGPT about your traffic." + "Free, open-source MCP server for web and AI agent analytics. Track events, pageviews " <> + "and per-user activity, and ask Claude, Cursor, VS Code or ChatGPT about your traffic." ) |> assign(:base_url, base_url(conn)) |> assign(:tools, WebAnalyticsWeb.MCP.Tools.definitions()) diff --git a/lib/web_analytics_web/controllers/landing_html/mcp.html.heex b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex index b157a5b..a8c1bca 100644 --- a/lib/web_analytics_web/controllers/landing_html/mcp.html.heex +++ b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex @@ -4,16 +4,16 @@

-

Analytics MCP Server

+

MCP Server for AI Agent Analytics

- Your analytics, inside Claude, Cursor and ChatGPT + The MCP server for your analytics — inside Claude, Cursor and ChatGPT

- SeriouslySimpleAnalytics is a free, unlimited Model Context Protocol server for web - analytics and AI agent analytics. Your assistant can create an account, add tracking - to a project, record events, and answer "how is my site doing?" from real traffic — - visitors, top pages, referrers, custom events, revenue metrics, individual users and - AI crawlers. + SeriouslySimpleAnalytics is a free, open-source, unlimited Model Context Protocol + server for web analytics and AI agent analytics. Your assistant can create an account, + add tracking to a project, record events, and answer "how is my site doing?" from real + traffic — visitors, top pages, referrers, custom events, revenue metrics, individual + users and AI crawler traffic (GPTBot, ClaudeBot, PerplexityBot).

@@ -40,7 +40,7 @@ hand your assistant one.

- Listed in the official MCP Registry as com.seriouslysimpleanalytics/analytics. + Listed in the official MCP Registry as com.seriouslysimpleanalytics/analytics-mcp-server.

diff --git a/lib/web_analytics_web/mcp/server.ex b/lib/web_analytics_web/mcp/server.ex index 99568ee..b730dc2 100644 --- a/lib/web_analytics_web/mcp/server.ex +++ b/lib/web_analytics_web/mcp/server.ex @@ -51,9 +51,9 @@ defmodule WebAnalyticsWeb.MCP.Server do "websiteUrl" => base_url <> "/analytics-mcp-server", "icons" => [ %{ - "src" => base_url <> "/images/icon-512.png", - "mimeType" => "image/png", - "sizes" => ["512x512"] + "src" => base_url <> "/images/logo.svg", + "mimeType" => "image/svg+xml", + "sizes" => ["any"] } ] } diff --git a/lib/web_analytics_web/mcp/tools.ex b/lib/web_analytics_web/mcp/tools.ex index 74f1f53..668eaa4 100644 --- a/lib/web_analytics_web/mcp/tools.ex +++ b/lib/web_analytics_web/mcp/tools.ex @@ -90,11 +90,11 @@ defmodule WebAnalyticsWeb.MCP.Tools do "title" => "Create a free analytics account", "access" => :open, "description" => - "Create a free, unlimited SeriouslySimpleAnalytics account for web analytics and AI agent " <> - "analytics, with no signup form. Returns the account ID used to track events and install " <> - "the website tag, an API key for reading reports through this server, and a one-time " <> - "claim link to the dashboard (or emails a sign-in link when `email` is given). Create " <> - "one account per project and reuse it — never one per run.", + "Create a free analytics account, no card required and no signup form, for web analytics " <> + "and AI agent analytics. Returns the account ID used to track events and install the " <> + "website tag, an API key for reading reports through this server, and a one-time claim " <> + "link to the dashboard (or emails a sign-in link when `email` is given). Create one " <> + "account per project and reuse it — never one per run.", "inputSchema" => %{ "type" => "object", "properties" => %{ @@ -311,8 +311,9 @@ defmodule WebAnalyticsWeb.MCP.Tools do read_tool( "get_metrics", "Metrics: revenue, tokens, counts", - "Numbers sent as event attributes, summed: revenue, sats, tokens, PRs, latency — each with " <> - "total, count, average, minimum and maximum. Name a `key` to chart it per hour or per day.", + "Custom numeric metrics: revenue, tokens, sats, PRs, latency, or any counter you define, " <> + "summed from event attributes — each with total, count, average, minimum and maximum. " <> + "Name a `key` to chart it per hour or per day.", %{ "key" => %{ "type" => "string", diff --git a/server.json b/server.json new file mode 100644 index 0000000..eb0afce --- /dev/null +++ b/server.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "com.seriouslysimpleanalytics/analytics-mcp-server", + "title": "SeriouslySimpleAnalytics", + "description": "Free web + AI-agent analytics: track events, pageviews & users from Claude, Cursor, VS Code.", + "version": "1.0.0", + "websiteUrl": "https://seriouslysimpleanalytics.com/analytics-mcp-server", + "repository": { + "url": "https://github.com/lbesecker195/SeriouslySimpleAnalytics", + "source": "github" + }, + "icons": [ + { + "src": "https://seriouslysimpleanalytics.com/images/logo.svg", + "mimeType": "image/svg+xml", + "sizes": ["any"] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://seriouslysimpleanalytics.com/mcp" + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "category": "analytics", + "audience": ["website-owners", "ai-agent-builders", "developers"] + } + } +} From fd9a37480080ea824aba0bc7036a36c21d5d0302 Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Fri, 18 Sep 2026 21:45:29 -0700 Subject: [PATCH 3/3] Show what the browser tag never saw The tag has to load and run before it can report, so anything that never executes JavaScript is invisible to it. That was unmeasurable until server-side recording existed. Now that the Phoenix plug reports the same pageviews the tag does, the gap between them is a number, and this tab is it. A pageview reported by the server alone carries no viewport height and no heartbeat, because a server has no viewport and does not sit on a page. Both are required before calling one untagged -- a visitor who leaves inside a second is gone before the first heartbeat, and either signal alone would misread them. - Splits misses into automated and not. A crawler missing the tag is expected; a person missing it is a blocked script or an untagged page, and the tab says so plainly - Counts crawlers even with the crawler filter on, since hiding the largest thing the tag misses would defeat the report - Groups by path, never title: a title is one of the things only the tag can supply 619 tests passing, 11 of them new. Closes #3 --- Pages affected: - [Seriously Simple Analytics](https://seriouslysimpleanalytics.com/) -- the dashboard this tab joins. - [AI crawler analytics](https://seriouslysimpleanalytics.com/ai-crawler-analytics) -- the automated half of what the tag misses. - [Analytics MCP server](https://seriouslysimpleanalytics.com/analytics-mcp-server) -- the same numbers, queryable from an agent. - [MCP Harbor](https://ai.mcpharbor.com/) -- a site already feeding this with server-side data. Co-Authored-By: Claude Opus 5 --- lib/web_analytics/analytics.ex | 118 +++++++++++++++ lib/web_analytics_web/live/dashboard_live.ex | 10 +- .../live/dashboard_live.html.heex | 115 ++++++++++++++ test/web_analytics/tag_coverage_test.exs | 142 ++++++++++++++++++ .../live/dashboard_live_test.exs | 27 ++++ 5 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 test/web_analytics/tag_coverage_test.exs diff --git a/lib/web_analytics/analytics.ex b/lib/web_analytics/analytics.ex index 51344ab..4faed86 100644 --- a/lib/web_analytics/analytics.ex +++ b/lib/web_analytics/analytics.ex @@ -2166,6 +2166,124 @@ defmodule WebAnalytics.Analytics do ) end + # -- tag coverage -------------------------------------------------------- + # + # What the browser tag missed, which is only answerable now that a server-side + # plug reports the same pageviews the tag does. Where both reported one, they + # merge into a single row; where only the server did, the row is missing + # everything a browser has to supply. + # + # Two markers, either of which settles it. The tag sends `window.innerHeight` + # on every pageview it opens, and it heartbeats afterwards; the plug sends + # neither, because a server has no viewport and does not stay on a page. So a + # pageview with no viewport height *and* no tick is one no tag ever reported. + # + # Either signal on its own would be wrong in a case that really happens: a + # visitor who leaves inside a second is gone before the first heartbeat, and a + # browser reporting no viewport height is unusual but not impossible. + + # Crawlers are the subject here, not noise in front of it, so the usual + # exclusion is deliberately not applied. A report about what the tag missed + # that hid the largest thing it misses would be worse than no report. + defp coverage_scope(f) do + from(p in Pageview, + join: s in assoc(p, :session), + as: :session, + where: p.site_id == ^f.site_id, + where: p.entered_at >= ^f.from and p.entered_at < ^f.to + ) + |> filter_joined_anomalies(f) + |> filter_joined_dwell(f) + |> filter_joined_origins(f) + |> filter_joined_sessions(f) + |> filter_joined_project(f) + |> filter_joined_host(f) + |> filter_joined_user(f) + end + + @doc """ + How much of this site's traffic the browser tag actually saw. + + Splits what it missed into automated and everything else, because the two + mean different things. Crawlers missing the tag is expected and is the reason + server-side recording exists. People missing it is a finding: blocked + scripts, a failed asset, a page the tag was never added to. + """ + def tag_coverage(f) do + totals = + Repo.one( + from [p, session: s] in coverage_scope(f), + select: %{ + pageviews: count(p.id), + tagged: filter(count(p.id), not is_nil(p.viewport_h) or p.tick_count > 0), + untagged: filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0), + untagged_crawler: + filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0 and s.crawler), + untagged_human: + filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0 and not s.crawler), + human_pageviews: filter(count(p.id), not s.crawler) + } + ) || %{} + + totals + |> Map.put(:coverage, rate(Map.get(totals, :tagged, 0), Map.get(totals, :pageviews, 0))) + |> Map.put( + :human_coverage, + rate( + Map.get(totals, :human_pageviews, 0) - Map.get(totals, :untagged_human, 0), + Map.get(totals, :human_pageviews, 0) + ) + ) + end + + @doc """ + The pages the tag never reported, most-missed first. + + Always grouped by path, never by title, because a title is one of the things + only the tag can supply — grouping these by title would return one unnamed + row holding everything. + """ + def untagged_pages(f, limit \\ 25) do + Repo.all( + from [p, session: s] in coverage_scope(f), + where: is_nil(p.viewport_h) and p.tick_count == 0, + group_by: p.path, + order_by: [desc: count(p.id)], + limit: ^limit, + select: %{ + name: p.path, + count: count(p.id), + crawler: filter(count(p.id), s.crawler), + human: filter(count(p.id), not s.crawler), + sessions: count(p.session_id, :distinct), + last_seen: max(p.entered_at) + } + ) + end + + @doc """ + What was reading the pages the tag never saw, by user agent. + + Answers the question the coverage number raises: if a tenth of this site is + invisible to the tag, who is that? + """ + def untagged_clients(f, limit \\ 15) do + Repo.all( + from [p, session: s] in coverage_scope(f), + where: is_nil(p.viewport_h) and p.tick_count == 0, + group_by: [s.crawler_name, s.crawler_kind, s.crawler], + order_by: [desc: count(p.id)], + limit: ^limit, + select: %{ + name: s.crawler_name, + kind: s.crawler_kind, + crawler: s.crawler, + count: count(p.id), + sessions: count(p.session_id, :distinct) + } + ) + end + # -- breakdowns ---------------------------------------------------------- @doc "Top values of a session dimension, e.g. `:browser` or `:referrer_host`." diff --git a/lib/web_analytics_web/live/dashboard_live.ex b/lib/web_analytics_web/live/dashboard_live.ex index 779d1cc..dcbde39 100644 --- a/lib/web_analytics_web/live/dashboard_live.ex +++ b/lib/web_analytics_web/live/dashboard_live.ex @@ -16,7 +16,7 @@ defmodule WebAnalyticsWeb.DashboardLive do alias WebAnalytics.Ingest.Crawler alias WebAnalytics.Sites - @tabs ~w(live overview users pages events metrics flow locations clicks forms sessions anomalies crawlers) + @tabs ~w(live overview users pages events metrics flow locations clicks forms sessions anomalies crawlers coverage) @click_groups ~w(name id class text selector tag) @location_levels ~w(country region county city) @flow_modes ~w(pages events) @@ -622,6 +622,14 @@ defmodule WebAnalyticsWeb.DashboardLive do } end + defp tab_data("coverage", filters, _assigns) do + %{ + tag_coverage: Analytics.tag_coverage(filters), + untagged_pages: Analytics.untagged_pages(filters), + untagged_clients: Analytics.untagged_clients(filters) + } + end + # Loaded by assign_live/1 on its own interval rather than here, so the five # second refresh does not run it too. defp tab_data("live", _filters, _assigns), do: %{} diff --git a/lib/web_analytics_web/live/dashboard_live.html.heex b/lib/web_analytics_web/live/dashboard_live.html.heex index 859e9a5..76c07df 100644 --- a/lib/web_analytics_web/live/dashboard_live.html.heex +++ b/lib/web_analytics_web/live/dashboard_live.html.heex @@ -2130,6 +2130,121 @@ + +
+ <% cov = @data[:tag_coverage] || %{} %> + +
+ What the browser tag missed. The tag has to load and run before it can report, so + anything that never runs JavaScript is invisible to it — crawlers and AI agents by + nature, and real visitors when a script is blocked or a page was never tagged. These + pages are here because the server-side plug recorded them anyway. +
+ +
+ <.stat label="Pageviews recorded" value={number(cov[:pageviews])} /> + <.stat + label="Seen by the tag" + value={percent(cov[:coverage])} + hint={"#{number(cov[:tagged])} of #{number(cov[:pageviews])}"} + tone={if (cov[:coverage] || 0) >= 90, do: "success", else: "neutral"} + /> + <.stat + label="Missed — automated" + value={number(cov[:untagged_crawler])} + hint="Expected. This is why the plug exists." + /> + <.stat + label="Missed — not automated" + value={number(cov[:untagged_human])} + hint={"#{percent(100 - (cov[:human_coverage] || 100))} of non-bot pageviews"} + tone={if (cov[:untagged_human] || 0) > 0, do: "warning", else: "success"} + /> +
+ +
0} + class="rounded-box border border-warning/40 bg-warning/10 px-4 py-3 text-sm" + > + {number(cov[:untagged_human])} pageviews + came from something that was not automated and still never ran the tag. That is usually + a blocked script, a failed asset, or a page the snippet was never added to — worth + checking, because those visits would be missing entirely without the plug. +
+ +
+ <.bar_list + title="Pages the tag never saw" + rows={@data[:untagged_pages] || []} + empty="The tag saw everything" + /> + + <.bar_list + title="What was reading them" + rows={ + Enum.map(@data[:untagged_clients] || [], fn row -> + %{name: row.name || "Not automated", count: row.count} + end) + } + empty="Nothing went unreported" + /> +
+ +
+
+ Untagged pages in full +
+ + + + + + + + + + + + + + + + + + + + + + + + +
PathPageviewsAutomatedNot automatedSessionsLast seen
{page.name}{number(page.count)}{number(page.crawler)} 0 && "text-warning font-medium" + ]}> + {number(page.human)} + {number(page.sessions)} + {page.last_seen && Calendar.strftime(page.last_seen, "%d %b %H:%M")} +
+ Nothing went unreported +
+
+ +
+ Not seeing anything here? + This tab only fills up once server-side recording is installed — + <.link + href="https://github.com/lbesecker195/Phoenix-Analytics" + class="link link-primary" + target="_blank" + rel="noopener" + > + the Phoenix plug + + reports the pages your tag cannot, on the same account and the same visits. +
+
+ <%!-- The instructions live on /getting-started now. An account with traffic scrolled past them on every visit, and an account with none had to scroll past every empty chart to reach the only thing it needed. --%> diff --git a/test/web_analytics/tag_coverage_test.exs b/test/web_analytics/tag_coverage_test.exs new file mode 100644 index 0000000..ab70164 --- /dev/null +++ b/test/web_analytics/tag_coverage_test.exs @@ -0,0 +1,142 @@ +defmodule WebAnalytics.TagCoverageTest do + @moduledoc """ + What the browser tag missed. + + Only answerable because a server-side plug reports the same pageviews the tag + does: where both reported one they merge into a single row, and where only the + server did, the row is missing everything a browser has to supply. + """ + + use WebAnalytics.DataCase, async: true + + import WebAnalytics.Fixtures + + alias WebAnalytics.Analytics + alias WebAnalytics.Ingest + + setup do + site = site_fixture() + + # Seen by the tag: a viewport, and heartbeats afterwards. + submit(site, [ + init_event(), + pageview_event(1, "/", %{"vh" => 900, "dh" => 4200}), + tick_event(1, %{"d" => 20_000, "sp" => 70}) + ]) + + # An agent, reported only by the plug: no viewport, no heartbeat, because + # nothing ran in a browser. + submit( + site, + [ + init_event(%{"ua" => "ClaudeBot/1.0 (+https://anthropic.com/claudebot)"}), + pageview_event(1, "/llms.txt", %{"title" => nil}), + pageview_event(2, "/docs", %{"title" => nil}) + ], + token: "agent-visit" + ) + + # A person whose JavaScript never ran. This is the interesting one: not a + # bot, and still invisible to the tag. + submit( + site, + [ + init_event(%{"ua" => "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/120 Safari/537"}), + pageview_event(1, "/pricing", %{"title" => nil}) + ], + token: "no-js-visit" + ) + + {:ok, site: site, filters: Analytics.filters(site.id, %{"range" => "24h"})} + end + + describe "tag_coverage/1" do + test "counts what the tag saw against everything recorded", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + assert coverage.pageviews == 4 + assert coverage.tagged == 1 + assert coverage.untagged == 3 + assert coverage.coverage == 25.0 + end + + test "separates automated misses from the ones worth worrying about", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + # A crawler missing the tag is expected and is the reason the plug exists. + assert coverage.untagged_crawler == 2 + + # A person missing it is a finding: a blocked script, or an untagged page. + assert coverage.untagged_human == 1 + end + + test "reports coverage of non-automated traffic separately", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + # Two non-bot pageviews, one of which the tag saw. + assert coverage.human_pageviews == 2 + assert coverage.human_coverage == 50.0 + end + + test "counts crawlers even though every other report hides them", %{site: site} do + # The default filters exclude crawlers. A report about what the tag misses + # that hid the largest thing it misses would be worse than no report. + filters = Analytics.filters(site.id, %{"range" => "24h", "crawlers" => "exclude"}) + + assert Analytics.tag_coverage(filters).untagged_crawler == 2 + end + end + + describe "untagged_pages/2" do + test "lists the pages the tag never reported", %{filters: f} do + paths = Analytics.untagged_pages(f) |> Enum.map(& &1.name) + + assert "/llms.txt" in paths + assert "/docs" in paths + assert "/pricing" in paths + refute "/" in paths, "the tag reported the home page" + end + + test "splits each page by whether the reader was automated", %{filters: f} do + pages = Analytics.untagged_pages(f) + + assert %{crawler: 1, human: 0} = Enum.find(pages, &(&1.name == "/llms.txt")) + assert %{crawler: 0, human: 1} = Enum.find(pages, &(&1.name == "/pricing")) + end + end + + describe "untagged_clients/2" do + test "says what was reading the pages the tag missed", %{filters: f} do + clients = Analytics.untagged_clients(f) + + assert %{count: 2} = Enum.find(clients, &(&1.name == "ClaudeBot")) + assert %{count: 1, crawler: false} = Enum.find(clients, &is_nil(&1.name)) + end + end + + describe "a site the plug is not installed on" do + test "reports full coverage rather than an alarming zero", %{site: site} do + other = site_fixture() + + submit(other, [ + init_event(), + pageview_event(1, "/", %{"vh" => 900}), + tick_event(1, %{"d" => 5_000}) + ]) + + coverage = Analytics.tag_coverage(Analytics.filters(other.id, %{"range" => "24h"})) + + assert coverage.coverage == 100.0 + assert coverage.untagged == 0 + assert Analytics.untagged_pages(Analytics.filters(other.id, %{"range" => "24h"})) == [] + + # And the first site is untouched by the second. + assert Analytics.tag_coverage(Analytics.filters(site.id, %{"range" => "24h"})).untagged == 3 + end + end + + defp submit(site, events, opts \\ []) do + {:ok, _} = + Ingest.submit_sync(site, payload(site, events, opts), received_at: DateTime.utc_now()) + end +end diff --git a/test/web_analytics_web/live/dashboard_live_test.exs b/test/web_analytics_web/live/dashboard_live_test.exs index d407c5f..e7095d5 100644 --- a/test/web_analytics_web/live/dashboard_live_test.exs +++ b/test/web_analytics_web/live/dashboard_live_test.exs @@ -221,6 +221,33 @@ defmodule WebAnalyticsWeb.DashboardLiveTest do assert html =~ "/secret-corner" end + test "the coverage tab shows the pages the tag never reported", %{conn: conn} do + {:ok, _live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage") + + # Both of these were recorded without a viewport or a heartbeat, which is + # what a page reported by the server alone looks like. + assert html =~ "/secret-corner" + assert html =~ "/pricing" + + assert html =~ "What the browser tag missed" + end + + test "the coverage tab calls out misses that were not automated", %{conn: conn} do + # A crawler missing the tag is expected. A person missing it is a finding, + # and the tab has to say so rather than lump the two together. + {:ok, _live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage") + + assert html =~ "Missed — not automated" + assert html =~ "never ran the tag" + end + + test "coverage counts crawlers even with the crawler filter on", %{conn: conn} do + {:ok, _live, html} = + live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage&crawlers=exclude") + + assert html =~ "/secret-corner" + end + test "flow can be grouped by path or by title", %{conn: conn} do {:ok, live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=flow")