PhoenixDevToolbar.Plug (PhoenixDevToolbar v0.0.2)

Copy Markdown View Source

The whole toolbar integration: tracks page requests, injects the toolbar into HTML responses, and serves the toolbar's own assets and panel.

Add it to your endpoint, next to Phoenix.LiveReloader:

if code_reloading? do
  plug PhoenixDevToolbar.Plug
end

No router changes are needed: the panel is a LiveView rendered by this plug under :path, and it connects through its own socket at <path>/live, not through the host's LiveSocket or its app.js. This keeps the package installable with only: :dev, since nothing outside the code_reloading? block references it.

Options

  • :path - where the toolbar lives, an absolute path below the root. Defaults to "/__pdt". The panel, its assets, its socket and the export are served under it.

  • :nonce_assign - the conn.assigns key holding a CSP nonce for the injected script tag. Defaults to :csp_nonce. A nonce based script-src needs the same nonce in the assign:

    # in a plug before this one, or in your CSP plug
    conn = Plug.Conn.assign(conn, :csp_nonce, nonce)
    
    # or, if your plug stores it elsewhere
    plug PhoenixDevToolbar.Plug, nonce_assign: :my_nonce

Standalone panel

The panel also opens in a browser tab of its own, for example from a browser-driving agent:

  • <path>/panel?path=/posts - the newest page whose path is exactly /posts
  • <path>/panel - the newest page
  • <path>/panel?sid=<sid> - one page, by the sid PhoenixDevToolbar.Report returns, or the data-pdt-sid of the toolbar's script tag on the page

A page is the request and the LiveView it mounted, as the toolbar shows it. The page is picked when the panel loads: reload the panel for a newer one. ?path= matches the path the page started at (a live patch keeps its LiveView's mount path).

?path= and the newest page find a page without its sid, so they follow the MCP client rule below: this machine unless mcp: [clients: ...] says otherwise. Other clients get an empty panel that says why; ?sid= works for any client the access rules let in.

Rows carry stable data-pdt-* attributes with raw values. Booleans are present only when true; missing values are left out.

ElementAttributes
#pdt-paneldata-pdt-root (root sid), data-pdt-sids (comma separated)
tab sectiondata-pdt-tab (plugin id); hidden tabs are in the DOM too
every event rowdata-pdt-row, data-pdt-sid, data-pdt-seq, data-pdt-duration-us
query rowdata-pdt-source, data-pdt-error, data-pdt-n-plus-one
n-plus-one rowdata-pdt-count, data-pdt-callbacks
callback rowdata-pdt-callback, data-pdt-event, data-pdt-error
render rowdata-pdt-error
client rowdata-pdt-event (the phx:* type)
request rowdata-pdt-kind, data-pdt-method, data-pdt-status, data-pdt-error
profile rowdata-pdt-callback, data-pdt-count, data-pdt-total-us, data-pdt-max-us
#pdt-emptydata-pdt-lookup: sid, path, newest or gated

The decision is ADR 14.

MCP server

<path>/mcp is an MCP server (Streamable HTTP, MCP 2025-03-26 to 2026-07-28) with read-only tools over PhoenixDevToolbar.Report: list_pages, page_summary, page_queries, page_callbacks, page_n_plus_one and page_errors. By default it answers only clients on this machine (a loopback peer or one of the machine's own addresses, no proxy headers), with an allowed Host and no foreign Origin. It is on with the toolbar:

config :phoenix_dev_toolbar, mcp: false                              # off
config :phoenix_dev_toolbar, mcp: [clients: ["192.168.1.0/24"]]     # also these
config :phoenix_dev_toolbar, mcp: [clients: :any]                   # anyone

Anything on this machine that relays to the dev server (an SSH forward, socat, a local proxy without X-Forwarded-* headers, a VM or container with user-mode NAT) counts as this machine. Client setup is in the README, the protocol details in ADR 15.

Access rules

The panel, its socket, the export and the MCP endpoint answer only a request whose Host cannot be rebound: an IP address, localhost, your endpoint's url: [host: ...] or a name listed in config :phoenix_dev_toolbar, allowed_hosts: [...]. The socket also refuses a missing Origin, or one that differs from the host the request was sent to, unless it is listed in allowed_origins: [...] (for a proxy or tunnel that rewrites the Host header). The MCP endpoint and the standalone panel's sid-less lookups add the client rule of the "MCP server" section; process actions (actions:) and the Eval plugin's code use the same "this machine" rule (:local). The README's "Docker or LAN access" section sums it all up.

The plug does nothing when the toolbar is disabled (config :phoenix_dev_toolbar, enabled: false).