@llui/mcp

Current package version: 0.15.2

Model Context Protocol server for LLui. Exposes debug tools for LLM-assisted development.

pnpm add -D @llui/mcp

Usage

The MCP server auto-connects to running LLui apps via the vite-plugin's mcpPort bridge (default port 5200). No manual setup needed -- just enable the plugin and point your MCP client at the server.

// vite.config.ts -- MCP is enabled by default
import llui from '@llui/vite-plugin'
export default defineConfig({ plugins: [llui({ mcpPort: 5200 })] })

Tools

State Inspection

Tool Description
get_state Get current component state
describe_state Describe state shape and types
search_state Search state tree by path or value

Messaging

Tool Description
send_message Dispatch a message to the component
validate_message Check if a message matches the Msg union

History and Replay

Tool Description
get_message_history List all dispatched messages
export_trace Export message trace for replayTrace
replay_trace Replay a trace and compare states

Bindings and DOM

Tool Description
get_bindings List all active bindings and their masks
why_did_update Explain which state change triggered a binding
trace_element Trace a DOM element back to its binding

Mask Debugging

Tool Description
decode_mask Decode a binding's dependency mask into state path names
mask_legend Show the full mask-to-path mapping

Snapshots

Tool Description
snapshot_state Save a named state snapshot
restore_state Restore a previously saved snapshot

Multi-Mount

Tool Description
list_components List all mounted component instances
select_component Select a component for subsequent commands

View and DOM

Tool Description
inspect_element Rich report: tag, attrs, classes, data-*, text, computed, box, bindings
get_rendered_html outerHTML of a selector (default = mount root), truncatable
dom_diff Compare expected HTML against rendered HTML
dispatch_event Synthesize a browser event; returns Msgs produced + resulting state
get_focus Active element info: selector, tag, selection range

Bindings and Scope

Tool Description
force_rerender Re-evaluate all bindings; returns indices that changed
each_diff Per-each-site add/remove/move/reuse per update
scope_tree Scope hierarchy with kind (root/show/each/branch/child/portal)
disposer_log Recent scope disposals with cause
list_dead_bindings Bindings that are dead or have never changed value
binding_graph state path -> binding indices (inverts compiler mask legend)

Effects

Tool Description
pending_effects Queued and in-flight effects
effect_timeline Phased log: dispatched -> in-flight -> resolved/cancelled
mock_effect Register match->response mock; next matching effect resolves with mock
resolve_effect Manually resolve a specific pending effect

Time Travel and Utilities

Tool Description
step_back Rewind N messages by replaying from init (pure mode default)
coverage Per-Msg variant fire counts + list of never-fired variants
diff_state Structured JSON diff between two state values
assert Evaluate eq/neq/exists/gt/lt/in against a state path
search_history Filter history by type, statePath change, effectType, or range

Eval

Tool Description
eval Arbitrary JS in page context; returns result + observability envelope

Functions

findWorkspaceRoot()

Walk up from start until we find a workspace root marker. Used by both the MCP server (writing the active marker) and the Vite plugin (watching it) so they agree on a single shared location regardless of which subdirectory each process happens to be running in.

Strong markers (workspace root): pnpm-workspace.yaml, .git directory. If neither is found anywhere up the chain, falls back to the highest package.json above start. For pnpm monorepos this finds the workspace root from any subpackage; for single-package projects it finds the package root.

function findWorkspaceRoot(start: string = process.cwd()): string

mcpActiveFilePath()

Path where the MCP server writes its active port marker. Vite plugins watch this file to auto-trigger browser-side __lluiConnect() whenever the MCP server starts, regardless of whether Vite or MCP started first.

function mcpActiveFilePath(cwd: string = process.cwd()): string

mcpHttpTokenPath()

Path for the per-launch HTTP bearer token used in --http mode. Lives next to the active marker (same workspace-rooted cache dir) so a same-user local client can read it, but is a SEPARATE 0600 file — the token is never written into the world-readable marker. Lives here (not in cli.ts) so tests can import the path without triggering the CLI's top-level main() side effect.

function mcpHttpTokenPath(cwd: string = process.cwd()): string

mcpStateDir()

Directory holding the MCP handshake state: the active-port marker and the per-launch HTTP bearer token.

Defaults to the workspace root's cache (not the immediate cwd) so the MCP server and the Vite plugin always agree on a single location even when one runs from the repo root and the other from a subpackage.

LLUI_MCP_STATE_DIR overrides it. Both this package and @llui/vite-plugin read that variable, so the two ends of the handshake move together — which is the point: the default is a single machine-global path per checkout, so two concurrent instances (two test runs, two agents driving the same repo) otherwise overwrite each other's marker and each connects the other's browser (issue #85).

Read per call, not memoized, so a process that sets the variable during startup is still honored.

function mcpStateDir(cwd: string = process.cwd()): string

Types

CdpBrowserLauncher

Launches the fallback browser. The default is Playwright's own chromium.launch: this is the DEVELOPER's debugging browser, pointed at their own app, which may legitimately load fonts, APIs and CDN assets — the repository's hermetic test network policy (scripts/lib/network-policy.mjs) must not govern it. Tests inject the hermetic launcher here so the browsers THEY cause to exist stay behind the guard.

export type CdpBrowserLauncher = (options: {
  headless: boolean
}) => Promise<import('playwright').Browser>

Interfaces

LluiMcpServerOptions

export interface LluiMcpServerOptions {
  /**
   * Port for the browser-relay WebSocket bridge. When the MCP transport
   * is stdio (the CLI default), the relay stands up its own server on
   * this port. When the MCP transport is HTTP, the relay attaches to
   * that HTTP server and the MCP protocol + bridge share a single port.
   *
   * `0` asks the OS for a free port; read the assigned one back with
   * `boundPort()` once `startBridge()` has resolved.
   */
  bridgePort?: number
  /**
   * Optional pre-existing `http.Server` to share with the bridge. When
   * provided, the bridge attaches to it via upgrade routing on
   * `/bridge`; `bridgePort` is ignored for server-creation purposes
   * (but still written into the marker file so consumers know where to
   * connect).
   */
  attachTo?: HttpServer
  /**
   * Optional dev-server URL for CDP fallback navigation. When provided,
   * the CDP session manager will use this URL as the target for Playwright
   * browser instances.
   */
  devUrl?: string
  /**
   * Whether to run the Playwright browser in headed mode (visible window).
   * Defaults to false (headless).
   */
  headed?: boolean
  /**
   * Launches the Playwright fallback browser behind the CDP tools. Defaults to
   * Playwright's `chromium.launch`; see `CdpBrowserLauncher` for why the
   * product browser is not network-policed and tests inject one that is.
   */
  launchBrowser?: CdpBrowserLauncher
  /**
   * How long the CDP fallback may take to navigate and see `__lluiDebug`.
   * Defaults to `DEFAULT_ATTACH_TIMEOUT_MS` (30 s, measured).
   */
  attachTimeoutMs?: number
  /**
   * Filesystem root for the devmode-annotate notebook
   * (https://github.com/fponticelli/llui — docs/proposals/devmode-annotate/).
   * MCP notes tools (`llui_list_notes`, `llui_read_note`, …) read from
   * this directory.
   *
   * Resolution order: this option → `LLUI_NOTES_DIR` env var → workspace
   * root + `.llui/notes`.
   */
  notesRoot?: string
  /**
   * Opt in to the arbitrary-eval tool (`llui_eval`). OFF by default.
   *
   * SECURITY: `llui_eval` runs caller-supplied JavaScript in the user's
   * live browser session (RCE). It is registered only when this flag is
   * true OR `LLUI_MCP_ENABLE_EVAL=1` is set; otherwise the tool is never
   * registered and never listed.
   */
  enableEval?: boolean
}

Classes

CdpError

class CdpError extends Error {
  constructor(public readonly code:
      | 'cdp_unavailable'
      | 'dev_url_unknown'
      | 'attach_timeout'
      | 'browser_crashed', message: string)
}

LluiMcpServer

class LluiMcpServer {
  registry: ToolRegistry
  relay: WebSocketRelayTransport
  requestedPort: number
  mcp: McpServer
  cdp: CdpSessionManager
  notesRoot: string
  devUrl: string | null
  constructor(opts: LluiMcpServerOptions = {})
  buildMcpServer(): McpServer
  createSessionMcp(): McpServer
  connect(transport: Transport): Promise<void>
  connectDirect(api: LluiDebugAPI): void
  setDevUrl(url: string): void
  startBridge(): Promise<void>
  boundPort(): number | null
  stopBridge(): void
  writeActiveFile(): void
  removeActiveFile(): void
  getTools(): ToolDefinition[]
  handleToolCall(name: string, args: Record<string, unknown>): Promise<unknown>
}

Constants

DEFAULT_ATTACH_TIMEOUT_MS

How long the Playwright fallback may take to ATTACH: navigate to the dev URL and see the app expose __lluiDebug. One deadline covers both steps.

Sized from measurement, not taste. Against examples/virtualization on a dev server, at load ~19 on 4 CPUs: 0.7-1.2 s cold, 0.5-1.0 s warm. The slow case this must still admit is a large app's FIRST on-demand compile, measured at 7.3-9.0 s under similar load for the components demo (docs/agents/tests-and-load.md, loose-d) — so 30 s leaves ~3x over the worst real attach and ~25x over a typical one, while a page that will never become ready (wrong URL, app without the dev runtime) still fails with a named attach_timeout instead of hanging the tool call.

It used to be written { timeout: 10_000 } in waitForFunction's SECOND argument slot — the page function's argument, not its options — so the wait silently ran on Playwright's default instead, and the error claimed "within 10s".

const DEFAULT_ATTACH_TIMEOUT_MS