From 5b789b84252479c8356c8210eb3d738c5665500c Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Thu, 23 Jul 2026 14:28:58 +0300 Subject: [PATCH] Add MCP endpoints --- .../controllers/mcp/mcp_controller.ex | 287 ++++++++++++++++++ lib/plausible_web/router.ex | 14 + test/plausible/oauth_test.exs | 23 ++ .../controllers/mcp/mcp_controller_test.exs | 256 ++++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 lib/plausible_web/controllers/mcp/mcp_controller.ex create mode 100644 test/plausible_web/controllers/mcp/mcp_controller_test.exs diff --git a/lib/plausible_web/controllers/mcp/mcp_controller.ex b/lib/plausible_web/controllers/mcp/mcp_controller.ex new file mode 100644 index 000000000000..5d679c20f716 --- /dev/null +++ b/lib/plausible_web/controllers/mcp/mcp_controller.ex @@ -0,0 +1,287 @@ +defmodule PlausibleWeb.MCP.MCPController do + @moduledoc """ + Model Context Protocol (MCP) endpoint over Streamable HTTP. + + Implements the minimal JSON-RPC 2.0 surface required by a remote connector: + `initialize`, `ping`, `tools/list`, `tools/call`, and `notifications/*` + (acknowledged with `202`). Responses are returned directly as + `application/json`; the optional SSE transport is not implemented (`GET /mcp` + returns `405`). + + Two tools are exposed: + + * `list_sites` - the sites of the token's bound team (scope `sites:read:*`). + * `query_stats` - wraps the Stats API `POST /api/v2/query` (scope + `stats:read:*`). + + Authentication, rate limiting and `current_user`/`current_team` assignment are + handled upstream by `PlausibleWeb.Plugs.AuthorizeOAuthAPI`. + """ + + use PlausibleWeb, :controller + use Plausible.Repo + + alias Plausible.Stats.{Query, QueryError} + + @protocol_version "2025-06-18" + @server_name "Plausible Analytics" + + @tools [ + %{ + name: "list_sites", + description: + "List the sites (websites) the authorized team has access to. Returns each site's domain and timezone.", + inputSchema: %{ + type: "object", + properties: %{}, + additionalProperties: false + } + }, + %{ + name: "query_stats", + description: + "Query analytics for a site using the Plausible Stats API v2. Provide the site's domain as `site_id`, the `metrics` to compute, and a `date_range`. Optionally provide `dimensions`, `filters`, `order_by` and other Stats API v2 query fields.", + inputSchema: %{ + type: "object", + properties: %{ + site_id: %{ + type: "string", + description: "The domain of the site to query, e.g. \"example.com\"." + }, + metrics: %{ + type: "array", + items: %{type: "string"}, + description: "List of metrics, e.g. [\"visitors\", \"pageviews\"]." + }, + date_range: %{ + description: + "A named range like \"7d\", \"30d\", \"month\", \"all\", or a [start, end] ISO-8601 pair." + }, + dimensions: %{type: "array", items: %{type: "string"}}, + filters: %{type: "array"}, + order_by: %{type: "array"}, + include: %{type: "object"}, + pagination: %{type: "object"} + }, + required: ["site_id", "metrics", "date_range"] + } + } + ] + + ## Streamable HTTP transport + + def handle(conn, _params) do + case conn.body_params do + %{"_json" => messages} when is_list(messages) -> + respond(conn, messages, Enum.map(messages, &dispatch(conn, &1))) + + %{"jsonrpc" => _} = message -> + respond(conn, [message], [dispatch(conn, message)]) + + _ -> + send_json(conn, 400, error_response(nil, -32_600, "Invalid Request")) + end + end + + def not_supported(conn, _params) do + send_json(conn, 405, error_response(nil, -32_000, "Method not allowed. Use POST for MCP.")) + end + + # A batch/single request may contain `initialize`, in which case we assign a + # session id via the `Mcp-Session-Id` response header. Requests without a + # response (only notifications) are acknowledged with `202`. + defp respond(conn, messages, responses) do + conn = maybe_put_session_id(conn, messages) + responses = Enum.reject(responses, &is_nil/1) + + case {conn.body_params, responses} do + {_, []} -> send_resp(conn, 202, "") + {%{"_json" => _}, _} -> send_json(conn, 200, responses) + {_, [response]} -> send_json(conn, 200, response) + {_, _} -> send_json(conn, 200, responses) + end + end + + defp maybe_put_session_id(conn, messages) do + if Enum.any?(messages, &(is_map(&1) and &1["method"] == "initialize")) do + put_resp_header(conn, "mcp-session-id", generate_session_id()) + else + conn + end + end + + ## JSON-RPC dispatch + + defp dispatch(_conn, %{"method" => "notifications/" <> _}), do: nil + + defp dispatch(_conn, %{"method" => "initialize", "id" => id} = message) do + requested = get_in(message, ["params", "protocolVersion"]) + version = if is_binary(requested), do: requested, else: @protocol_version + + result = %{ + protocolVersion: version, + capabilities: %{tools: %{}}, + serverInfo: %{name: @server_name, version: app_version()} + } + + success_response(id, result) + end + + defp dispatch(_conn, %{"method" => "ping", "id" => id}) do + success_response(id, %{}) + end + + defp dispatch(_conn, %{"method" => "tools/list", "id" => id}) do + success_response(id, %{tools: @tools}) + end + + defp dispatch(conn, %{"method" => "tools/call", "id" => id, "params" => params}) do + name = params["name"] + arguments = params["arguments"] || %{} + + case call_tool(conn, name, arguments) do + {:ok, result} -> + success_response(id, %{content: [text_content(result)], isError: false}) + + {:error, message} -> + success_response(id, %{content: [text_content(message)], isError: true}) + end + end + + defp dispatch(_conn, %{"id" => id, "method" => method}) do + error_response(id, -32_601, "Method not found: #{method}") + end + + defp dispatch(_conn, _message), do: nil + + ## Tools + + defp call_tool(conn, "list_sites", _arguments) do + with :ok <- require_scope(conn, "sites:read:*") do + user = conn.assigns.current_user + team = conn.assigns.current_team + + sites = + Plausible.Sites.for_user_query(user, team) + |> Repo.all() + |> Enum.map(&%{domain: &1.domain, timezone: &1.timezone}) + + {:ok, %{sites: sites}} + end + end + + defp call_tool(conn, "query_stats", arguments) do + with :ok <- require_scope(conn, "stats:read:*"), + {:ok, site_id} <- fetch_string(arguments, "site_id"), + {:ok, site} <- find_site(site_id), + :ok <- verify_site_access(conn, site) do + site = Repo.preload(site, :owners) + params = Map.put(arguments, "site_id", site.domain) + + case Query.parse_and_build(site, params) do + {:ok, query} -> + {:ok, Plausible.Stats.query(site, query)} + + {:error, %QueryError{message: message}} -> + {:error, message} + end + end + end + + defp call_tool(_conn, name, _arguments) do + {:error, "Unknown tool: #{name}"} + end + + ## Authorization helpers + + defp require_scope(conn, required) do + scopes = conn.assigns[:oauth_scopes] || [] + + granted? = + Enum.any?(scopes, fn scope -> + String.starts_with?(required, String.trim_trailing(scope, "*")) + end) + + if granted? do + :ok + else + {:error, "The access token does not grant the required scope: #{required}"} + end + end + + defp find_site(site_id) do + query = + from s in Plausible.Site, + where: s.domain == ^site_id or s.domain_changed_from == ^site_id + + case Repo.one(query) do + %Plausible.Site{} = site -> {:ok, site} + nil -> {:error, "Site not found or not accessible: #{site_id}"} + end + end + + defp verify_site_access(conn, site) do + user = conn.assigns.current_user + team = conn.assigns.current_team + site_team = Repo.preload(site, :team).team + + cond do + Plausible.Auth.super_admin?(user.id) -> + :ok + + team && team.id != site.team_id -> + {:error, "The access token is not authorized for this site's team."} + + Plausible.Teams.locked?(site_team) -> + {:error, "This site is locked due to a missing active subscription."} + + Plausible.Billing.Feature.StatsAPI.check_availability(site_team) != :ok -> + {:error, "The team that owns this site does not have access to the Stats API."} + + Plausible.Teams.Memberships.site_member?(site, user) -> + :ok + + true -> + {:error, "You do not have access to this site."} + end + end + + ## JSON-RPC + HTTP helpers + + defp success_response(id, result) do + %{jsonrpc: "2.0", id: id, result: result} + end + + defp error_response(id, code, message) do + %{jsonrpc: "2.0", id: id, error: %{code: code, message: message}} + end + + defp text_content(value) when is_binary(value) do + %{type: "text", text: value} + end + + defp text_content(value) do + %{type: "text", text: Jason.encode!(value)} + end + + defp fetch_string(map, key) do + case map[key] do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, "Missing required argument: #{key}"} + end + end + + defp send_json(conn, status, body) do + conn + |> put_status(status) + |> json(body) + end + + defp generate_session_id() do + :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + end + + defp app_version() do + to_string(Application.spec(:plausible, :vsn) || "0.0.0") + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index d9137c60ed46..5ed0053b93a3 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -491,6 +491,20 @@ defmodule PlausibleWeb.Router do post "/token", OAuth.TokenController, :token end + # MCP endpoint over Streamable HTTP, authenticated with an OAuth access token. + # Defined before the catch-all `/:domain` dashboard routes so the literal path + # wins. + scope "/", PlausibleWeb do + pipe_through [ + :external_api, + PlausibleWeb.Plugs.EnsureMCPEnabled, + PlausibleWeb.Plugs.AuthorizeOAuthAPI + ] + + post "/mcp", MCP.MCPController, :handle + get "/mcp", MCP.MCPController, :not_supported + end + scope "/", PlausibleWeb do pipe_through [:shared_link] diff --git a/test/plausible/oauth_test.exs b/test/plausible/oauth_test.exs index 6c17ca404cb9..b49c7c0efe92 100644 --- a/test/plausible/oauth_test.exs +++ b/test/plausible/oauth_test.exs @@ -340,6 +340,29 @@ defmodule Plausible.OAuthTest do assert remaining == Enum.sort([kept_other_user.id, kept_other_team.id]) end + test "removing a member from the team eagerly revokes their grants", %{ + user: owner, + team: team + } do + member = add_member(team, role: :editor) + insert_token(member, team, []) + assert [_] = OAuth.list_grants(member) + + {:ok, _} = Plausible.Teams.Memberships.Remove.remove(team, member.id, owner) + + assert OAuth.list_grants(member) == [] + end + + test "a member leaving the team eagerly revokes their grants", %{team: team} do + member = add_member(team, role: :editor) + insert_token(member, team, []) + assert [_] = OAuth.list_grants(member) + + {:ok, _} = Plausible.Teams.Memberships.Leave.leave(team, member) + + assert OAuth.list_grants(member) == [] + end + test "grants are cascade-deleted at the DB level when the user is deleted", %{ user: user, team: team diff --git a/test/plausible_web/controllers/mcp/mcp_controller_test.exs b/test/plausible_web/controllers/mcp/mcp_controller_test.exs new file mode 100644 index 000000000000..b97acd9741e4 --- /dev/null +++ b/test/plausible_web/controllers/mcp/mcp_controller_test.exs @@ -0,0 +1,256 @@ +defmodule PlausibleWeb.MCP.MCPControllerTest do + use PlausibleWeb.ConnCase, async: false + + alias Plausible.Repo + alias Plausible.OAuth.{AccessToken, Token} + + setup do + FunWithFlags.enable(:mcp_server) + on_exit(fn -> FunWithFlags.Store.Cache.flush() end) + + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, user: user, team: team} + end + + defp issue_token(user, team, scopes) do + access = Token.generate(:access) + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + client_id: "https://client.example/meta", + scopes: scopes, + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + ) + + access.raw + end + + defp rpc(conn, token, body) do + conn + |> put_req_header("authorization", "Bearer #{token}") + |> put_req_header("content-type", "application/json") + |> post("/mcp", Jason.encode!(body)) + end + + describe "authentication" do + test "401 with WWW-Authenticate when no token is provided", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/mcp", Jason.encode!(%{jsonrpc: "2.0", id: 1, method: "ping"})) + + assert json_response(conn, 401) + [header] = get_resp_header(conn, "www-authenticate") + assert header =~ "Bearer resource_metadata=" + assert header =~ "/.well-known/oauth-protected-resource" + end + + test "401 invalid_token with a bogus bearer token", %{conn: conn} do + conn = rpc(conn, "not-a-real-token", %{jsonrpc: "2.0", id: 1, method: "ping"}) + + assert json_response(conn, 401) + [header] = get_resp_header(conn, "www-authenticate") + assert header =~ ~s(error="invalid_token") + end + + test "404 when the flag is disabled", %{conn: conn, user: user, team: team} do + FunWithFlags.disable(:mcp_server) + token = issue_token(user, team, ["stats:read:*"]) + conn = rpc(conn, token, %{jsonrpc: "2.0", id: 1, method: "ping"}) + assert json_response(conn, 404) + end + end + + describe "JSON-RPC methods" do + setup %{user: user, team: team} do + {:ok, token: issue_token(user, team, ["stats:read:*", "sites:read:*"])} + end + + test "initialize returns capabilities and a session id", %{conn: conn, token: token} do + conn = + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: %{protocolVersion: "2025-06-18"} + }) + + resp = json_response(conn, 200) + assert resp["result"]["protocolVersion"] == "2025-06-18" + assert resp["result"]["capabilities"]["tools"] == %{} + assert resp["result"]["serverInfo"]["name"] == "Plausible Analytics" + assert [_session_id] = get_resp_header(conn, "mcp-session-id") + end + + test "ping returns an empty result", %{conn: conn, token: token} do + conn = rpc(conn, token, %{jsonrpc: "2.0", id: 2, method: "ping"}) + assert json_response(conn, 200) == %{"jsonrpc" => "2.0", "id" => 2, "result" => %{}} + end + + test "notifications are acknowledged with 202 and no body", %{conn: conn, token: token} do + conn = rpc(conn, token, %{jsonrpc: "2.0", method: "notifications/initialized"}) + assert response(conn, 202) == "" + end + + test "tools/list returns list_sites and query_stats", %{conn: conn, token: token} do + conn = rpc(conn, token, %{jsonrpc: "2.0", id: 3, method: "tools/list"}) + resp = json_response(conn, 200) + names = Enum.map(resp["result"]["tools"], & &1["name"]) + assert "list_sites" in names + assert "query_stats" in names + end + + test "unknown method returns a JSON-RPC error", %{conn: conn, token: token} do + conn = rpc(conn, token, %{jsonrpc: "2.0", id: 4, method: "does/not/exist"}) + resp = json_response(conn, 200) + assert resp["error"]["code"] == -32_601 + end + end + + describe "tools/call list_sites" do + test "returns the team's sites", %{conn: conn, user: user, team: team} do + site = new_site(owner: user) + token = issue_token(user, team, ["sites:read:*"]) + + conn = + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: %{name: "list_sites", arguments: %{}} + }) + + resp = json_response(conn, 200) + refute resp["result"]["isError"] + [%{"type" => "text", "text" => text}] = resp["result"]["content"] + payload = Jason.decode!(text) + domains = Enum.map(payload["sites"], & &1["domain"]) + assert site.domain in domains + end + + test "is denied without the sites:read scope", %{conn: conn, user: user, team: team} do + token = issue_token(user, team, ["stats:read:*"]) + + conn = + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: %{name: "list_sites", arguments: %{}} + }) + + resp = json_response(conn, 200) + assert resp["result"]["isError"] + [%{"text" => text}] = resp["result"]["content"] + assert text =~ "sites:read:*" + end + end + + describe "tools/call query_stats" do + test "wraps the Stats API query", %{conn: conn, user: user, team: team} do + subscribe_to_business_plan(team) + site = new_site(owner: user) + token = issue_token(user, team, ["stats:read:*"]) + + conn = + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: %{ + name: "query_stats", + arguments: %{ + site_id: site.domain, + metrics: ["visitors"], + date_range: "all" + } + } + }) + + resp = json_response(conn, 200) + refute resp["result"]["isError"] + [%{"text" => text}] = resp["result"]["content"] + payload = Jason.decode!(text) + assert Map.has_key?(payload, "results") + end + + test "returns an error for a site the token cannot access", %{ + conn: conn, + user: user, + team: team + } do + other_site = new_site() + token = issue_token(user, team, ["stats:read:*"]) + + conn = + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: %{ + name: "query_stats", + arguments: %{site_id: other_site.domain, metrics: ["visitors"], date_range: "all"} + } + }) + + resp = json_response(conn, 200) + assert resp["result"]["isError"] + end + + test "a role downgrade keeps the grant but reduces access via live checks", %{conn: conn} do + owner = new_user() + {:ok, team} = Plausible.Teams.get_or_create(owner) + site = new_site(owner: owner) + member = add_member(team, role: :editor) + token = issue_token(member, team, ["stats:read:*", "sites:read:*"]) + + list_sites = fn -> + rpc(conn, token, %{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: %{name: "list_sites", arguments: %{}} + }) + |> json_response(200) + end + + # Baseline: as an editor, the member sees the team's site. + [%{"text" => text}] = list_sites.()["result"]["content"] + assert site.domain in Enum.map(Jason.decode!(text)["sites"], & &1["domain"]) + + # Downgrade the member to a guest (a permission change, not a deletion). + {:ok, membership} = Plausible.Teams.Memberships.get_team_membership(team, member.id) + + membership + |> Ecto.Changeset.change(role: :guest) + |> Repo.update!() + + # The grant is NOT revoked - downgrade isn't a membership deletion... + assert [_] = Plausible.OAuth.list_grants(member) + + assert rpc(conn, token, %{jsonrpc: "2.0", id: 9, method: "ping"}) + |> json_response(200) == %{"jsonrpc" => "2.0", "id" => 9, "result" => %{}} + + # ...but the live per-request check now excludes guests, so no team sites + # are visible. + assert Jason.decode!(hd(list_sites.()["result"]["content"])["text"])["sites"] == [] + end + + test "GET /mcp is not supported", %{conn: conn, user: user, team: team} do + token = issue_token(user, team, ["stats:read:*"]) + + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> get("/mcp") + + assert json_response(conn, 405) + end + end +end