Lotus has an MCP server, Lotus.MCP (see the MCP guide of Lotus): Claude Code, Claude Desktop or any MCP client signs in through your app's login and works with your Lotus data, its saved queries and dashboards, without opening the dashboard. Lotus Web adds two things to it in Lotus.Web.MCP:

  • Your resolver decides, as in the dashboard. The Lotus.Web.Resolver you already wrote for the dashboard says who may use MCP and what each person may do there: the same access levels, the same authorize/3 decisions, the same actor.
  • Charts in the conversation. The show_chart tool charts a saved query or a statement with Lotus Web's charts, and hosts that render MCP Apps (Claude on the web and desktop, and others) draw it inline, in light or dark.

Serve Lotus.Web.MCP instead of Lotus.MCP; it has every Lotus tool.

Setup

Set up Sake's authorization first, as the Lotus MCP guide says: the {:sake, "~> 0.4"} dependency, a subject module, the Sake child and the OAuth routes. List Lotus.Web.MCP in the Sake child's servers:, then:

# router.ex
scope "/" do
  sake_endpoint "/lotus/mcp", server: Lotus.Web.MCP, auth: true
end
# config/config.exs
config :lotus_web, Lotus.Web.MCP, resolver: MyApp.LotusResolver

config :lotus, :mcp,
  authorize: {Lotus.Web.MCP, :authorize?},
  actor: {Lotus.Web.MCP, :actor}
claude mcp add --transport http lotus https://myapp.example.com/lotus/mcp

The principal your subject module's fetch/1 returns is the user your resolver's callbacks receive, as resolve_user/1's is in the dashboard. If the two differ (a subject that loads an account, a resolver that expects the conn's current scope), make the subject return what the resolver expects.

Who may do what

  • resolve_access/1 decides who may use MCP at all: :all and :read_only may, anything else may not. A resolver without resolve_access/1 lets nobody in: the dashboard's default of :all relies on your router's pipeline, which an MCP endpoint does not have.

  • authorize/3 decides each call, with the action the dashboard would ask about:

    ToolActionResource
    list_data_sources, list_schemas, list_tables, describe_table:discover or :querythe data source
    get_column_values, run_sql:querythe data source
    list_queries, get_query, run_query, show_chart:querythe query's data source
    save_query:create_query, and :querynil, and the data source
    update_query, save_chart:update_querythe query
    list_dashboards, get_dashboard, run_dashboard:view_dashboardthe dashboard
    create_dashboard:manage_dashboardnil
    add_dashboard_card:manage_dashboard, and :query for a query cardthe dashboard, and the query's data source

    Without authorize/3, the access level decides, as in the dashboard: a :read_only person may discover, query and view dashboards, and gets no tool that saves.

  • resolve_context/1 and resolve_scope/1 are the actor Lotus gets, so your middleware and visibility resolver see the same person they see in the dashboard.

The tool list shows what the person's access level allows; a call then asks authorize/3 about its resource, and a refusal comes back to the client with your resolver's reason. The list tools leave out the data sources, queries and dashboards the person may not see. The OAuth scopes (lotus:read, lotus:query, lotus:write) still apply on top: a token without lotus:write saves nothing, whatever the resolver says.

Charts

"Chart signups by week as a line chart"
→ run_sql to find the data, then show_chart with chart_type "line"

"Show me the Revenue query's chart"
→ show_chart with the query's id: its own chart, as Lotus Web shows it

show_chart takes a saved query (query_id, with vars) or a statement (data_source and statement), and either the chart arguments (chart_type, x_field, y_field, series_field, value_field, ...) or, for a saved query, nothing: the chart saved with it. Fields that are not columns of the result are refused with the columns there are. It saves nothing; save_chart keeps a chart with a saved query, and add_dashboard_card puts it on a dashboard.

The chart is an MCP App, ui://lotus/chart: one self-contained document (Vega-Lite, with no network access), drawn from the tool's result, in the host's light or dark theme. A host that does not render apps, such as Claude Code in a terminal, gets the result as data: the chart's Vega-Lite spec with its rows, at most 500.

Mounting in your own server

defmodule MyApp.MCP do
  use Sake.Server, name: "my-app", version: "1.0.0"
  tools [MyApp.MCP.SearchOrders]
  mount Lotus.Web.MCP, prefix: "lotus"
end

The resolver's decisions and the chart app come with the mount; tool names take the prefix (lotus_show_chart).

Prompts of your own

Lotus ships no MCP prompts, since it doesn't know what your data means. Add your own, about your own saved queries and dashboards, to the server that mounts Lotus.Web.MCP (Claude Code lists them as slash commands):

defmodule MyApp.MCP do
  use Sake.Server, name: "my-app", version: "1.0.0"
  mount Lotus.Web.MCP, prefix: "lotus"
  prompts [MyApp.MCP.Prompts.WeeklyRevenue]
end

The Lotus MCP guide has an example prompt. A prompt only writes the request: the model then calls the Lotus tools, which your resolver decides on as usual. Your resolver doesn't see the prompt itself, so give it a scope: (or middleware of your own) if not everyone should see it.

Assets

The chart app's script is built with the dashboard's own assets (mix assets.build runs esbuild mcp_chart, which writes priv/static/mcp_chart.js). The Hex package ships it built; working from a Git checkout of Lotus Web, build the assets before compiling.