Skip to content

mcpx: a web frontend served by the daemon, without a second build system #1882

Closed

Description

@dezren39

Relocated from dezren39/darwin-nix#86 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).

Parent: #174

Summary

A browser view of a running daemon. The daemon is already a web server — HTTP over a unix socket and
loopback TCP, /v1/*, SSE at /v1/events, an OpenAPI document at /v1/openapi.json, MCP at /mcp — so
"web server" is done and "web frontend" is the whole of the work. The decision is not whether to serve
a UI but how much build system to accept for it, because mcpx is one buildGoModule derivation today and
a bundled SPA makes it two.

Original notes

  • web frontend
  • web server

Current state (what mcpx does today, with file refs)

  • The listener exists. The daemon serves HTTP on a unix socket and, with daemon.port, on TCP;
    daemon.address (default 127.0.0.1) chooses the interface
    (internal/settings/registry.go:343-375). Nothing serves static content — every route is registered
    explicitly in internal/daemon/ops.go, routes_proto.go, routes_settings.go, routes_consumer.go,
    routes_resolve.go, routes_exec.go; there is no http.FileServer and no / handler.
  • The data a UI needs is all there. 60 operations across internal/api/ops*.go, one table that
    already generates routes, MCP tools and the OpenAPI document. Every view the TUI has —
    browse/log/stats/servers/sessions/storage (internal/tui/tui.go:41-51) — is a /v1 call, and the
    read surface is captured as an eight-method interface (internal/tui/data.go:19-28).
  • Live updates exist. GET /v1/events is SSE (internal/api/ops.go:156), honours Last-Event-ID so a
    reconnecting EventSource resumes by itself, and says event: gap in-band when it cannot
    (internal/daemon/server.go:797-842).
  • An open stream does not keep the daemon alive today. The idle clock is stamped when a request starts
    (server.go:344-353), so an open tab holding /v1/events is not activity after it connects; see chore(deps): bump actions/setup-go from 4 to 5 #87.
  • go:embed is already in use (internal/defaults/defaults.go:24), so embedding assets costs no new
    mechanism.
  • The nix package is deliberately minimal: subPackages = [ "cmd/mcpx" ], one vendorHash, and a
    comment block that enumerates and justifies each of five direct Go dependencies
    (pkgs/mcpx/package.nix:36-53).

.lootbox/ui — assess before reusing

36 tracked files under .lootbox/ui: React 18, Vite 6, Tailwind 3, a shadcn/radix component set
(components/ui/*), @tanstack/react-query, zustand, monaco, and exactly one page,
src/pages/Dashboard.tsx (193 lines) behind a 34-line App.tsx. A prebuilt bundle is committed
(assets/index-B2p0eWoj.css, assets/index-BkA-9JbN.js); dist/ is empty; node_modules/ is 279 MB and
untracked. #49 already records that this copy is not built by the flake — the lootbox that actually
runs is built from upstream (flake.nix:388) — so it has no build check and nothing depends on it.

Verdict for this issue: it is lootbox's dashboard, not mcpx's, and its one page is not a starting point
for an mcpx UI. The reusable part is the shadcn component set, which is a copy of an upstream generator's
output and can be regenerated at any time. It should not be adopted as mcpx's frontend, and #49's other
option — delete it — is not blocked by anything here.

Approaches

Approach A — vendor a React SPA into the repo and bundle it at build time, least recommended

ui/ with Vite + React + Tailwind, npm run build into internal/web/dist, go:embed the result, serve
it at /.

Why not: it turns one derivation into two with two fixed-output hashes. buildGoModule grows a
buildNpmPackage stage and an npmDepsHash that Renovate will break on a cadence — #49 is already about
the vendorHash/npmDepsHash repair step never having run on a real bot PR, and this doubles the surface it
must handle. It also means the committed tree contains generated bundles (as .lootbox/ui does) or that
nix build .#mcpx requires network-free npm vendoring for a UI most users will never open. A 280 MB
node_modules in a repo whose Go side is proud of five dependencies is a values mismatch, not just a size
one. And the maintenance is not the initial build: it is React majors, Tailwind majors and Vite majors
arriving on a project whose owner is writing Go.

Approach B — publish the contract and let the UI be someone else's repo

Do no frontend work. Make /v1/openapi.json good enough that openapi-typescript or oapi-codegen
produces a usable client, document the SSE stream in the same document, and point at it from the README.
Anyone who wants a dashboard generates a client and builds one outside this repo.

This is genuinely defensible and is most of the value at nearly no cost. The OpenAPI document is generated
from the same ops table as the routes (internal/api/openapi.go), so it cannot drift, which is exactly the
property a generated client needs. docs/proposals.md:440 already gestures at this — "a dashboard, a test
harness, another language's client generator — use the same…".

Where it falls short: a contract is not a UI. Nobody gets a browser view by default, "open the dashboard"
stays a multi-step setup, and the honest outcome of "someone will build it" is that nobody does. It is also
not mutually exclusive with shipping something — the work below assumes the OpenAPI document is good, and
any gaps found while building a UI against it are bugs in the contract worth fixing anyway.

Worth checking cheaply as part of this: does /v1/openapi.json currently pass openapi-typescript and
spectral lint clean? If it does not, that is a small, high-leverage fix regardless of which approach wins.

Approach C — recommended: a zero-build frontend embedded in the binary, plus a directory escape hatch

Recommended. Ship a small, dependency-free frontend — plain HTML, CSS and ES modules, no bundler, no npm,
no transpile — go:embeded into the binary and served by the existing listener. Modern browsers run ES
modules and import directly; a five-file frontend that calls fetch('/v1/...') and subscribes to
EventSource('/v1/events') needs no build step at all. nix build .#mcpx stays one derivation with one
hash, and mcpx daemon --ui is a dashboard on a machine that has nothing installed but a browser.

Shape:

decision choice why
where the daemon's existing listener, at /ui/ a second listener is a second lifetime, a second address and a second thing to leak; the daemon's idle-exit and reload already work
enabling one setting, daemon.ui (bool, scope daemon, default off) matches how every other capability is exposed (internal/settings/); off by default so a daemon on daemon.address=0.0.0.0 does not silently gain a UI
overriding daemon.uiDir (string, default empty) serves a directory instead of the embedded assets the escape hatch that makes A unnecessary: anyone who wants a React SPA builds it wherever they like and points the daemon at dist/. Also the dev loop — edit, reload, no rebuild
data /v1 + /v1/events only, no UI-specific endpoints the parity rule (docs/parity.md) is CLI = /v1 = MCP = plugin; a /ui/api prefix would be a fifth surface that drifts
scope v1 the TUI's six views, read-only, plus answering elicitations elicitation is the one thing a browser is better at than the terminal — a form with a schema beats a prompt — and /v1/elicit/{id}/{action} already exists
idle apply #87's lifetime rule (#181 collision 9): activity on any surface renews the daemon's idle deadline, and nothing else does today an open /v1/events stream counts only when it connects, so daemon.idleExit can kill the daemon under an open tab. Whether a delivering stream counts as activity is decided once, in #87, for this surface and ssh (#85) together -- not here

Why this beats A: the cost that kills A is the toolchain, not the UI, and a no-build frontend has no
toolchain. Why it beats B: B's contract work is a prerequisite of C, not an alternative — building the
embedded UI against /v1 is the thing that proves the OpenAPI document is usable, and C ends with B's
deliverable plus a working dashboard.

The real risk of C is scope creep: without a framework, view number seven is where hand-written DOM code
starts hurting. Mitigate by fixing the scope at "the TUI's views, plus elicitation forms" and treating
anything beyond that as a reason to reach for daemon.uiDir and an external project. If the UI outgrows
no-build, daemon.uiDir means moving it out is a configuration change rather than a rewrite.

One more reason to prefer the embedded route: it makes the remote story work end to end. With the ssh
endpoint scheme in the sibling issue, ssh -L 7777:... plus http://127.0.0.1:7777/ui/ is a remote
dashboard with nothing installed locally — which is most of what the "ssh tui" want is actually asking for.

Open questions

  • /ui/ or /? Serving at / risks a future /v1-adjacent path colliding and makes a 404 ambiguous
    between "no such route" and "no such asset". /ui/ with a redirect from / is probably right.
  • Does the UI need write operations in v1, or is read-only + elicitation the correct first cut? Write
    operations (restart, shutdown, servers_remove) are marked Admin/Destructive in the ops table
    (internal/api/ops.go:451-466, internal/api/ops_settings.go:111), and a browser is exactly where a
    misclick happens.
  • Is .lootbox/ui deleted, kept as a reference, or given a CI build? build(deps): bump build/buck/preludes/buck2-prelude from d956689 to 3cfc766 #49 owns the decision; this issue's
    input is that it is not a useful base for mcpx.
  • Does /v1/openapi.json currently generate clean with openapi-typescript? Cheap to check, informs both
    B and C.
  • Browser support floor: ES modules and EventSource are universal, but import maps and <template>-based
    rendering vary. Writing the floor down avoids a debate later.

Ease · Impact · Related

Ease: S to serve a directory and add the settings; M for the embedded read-only frontend covering
the TUI's six views; L if a bundled SPA (A) is chosen. Impact: med — high for demos, remote
machines and anyone who will not open a terminal; low for the existing CLI-first user.
Depends on / related: #49 (.lootbox/ui has no build check and may be deleted; any new build stage
needs CI), #76 (CLI parity — a UI makes /v1-only operations visible to more people), #77 (protocol
follow-ups: inline answers and task status are what a live dashboard would want), #87 (owns the lifetime
rule this issue applies, collision 9; also touch-on-read for the elicitations the UI answers), #85 (a
forwarded port plus /ui/ is the remote dashboard), #88 (the forwarded port needs a knowable socket path),
#52 (closed, fixed in #182).

Security/auth deferred; see tracker.

Activity

  1. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  2. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    History carried over from dezren39/darwin-nix#86 (verbatim, oldest first):


    @dezren39 · 2026-09-30T07:21:34Z

    Integration pass. Placed in the DAG rooted at #181.


    @dezren39 · 2026-09-30T07:28:15Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    Three surfaces, one lifetime question

    A browser tab versus daemon.idleExit (#86), leftover ssh ControlMaster processes (#85), and leaked e2e daemons (#52) are the same class of problem: who ends a session nobody explicitly closed. #86 says "decide deliberately" about its own case. Decide it once for all three.

    Also on: #85 #52. Nobody currently owns this.


    @dezren39 · 2026-09-30T11:05:52Z

    Collision ownership (issues pass, 2026-09-30, at 05c78b2). #181 listed these cross-theme collisions with no owner. Each now has one:

    #181 row Collision Owner What the owner decides
    9 one lifetime question on three surfaces (browser tab vs daemon.idleExit, ssh ControlMaster, leaked e2e daemons) #87 #87's touch-on-read convention is the lifetime rule: activity on any surface renews the daemon's idle deadline, and nothing else does. #85 and #86 apply it. The e2e case is closed: #182 fixed #52 (stop --all via info files, MCPX_AUTOSTART_IDLE_EXIT=60s).

    Where this issue is the owner, the decision is made here; where it is not, it adopts the owner's decision rather than making its own.


    @dezren39 · 2026-09-30T12:12:16Z

    Issues pass (2026-09-30, at 05c78b2).


    @dezren39 · 2026-10-28T21:09:48Z

    Triage verdict — partially implemented in the new home; remainder confirmed, not closeable as done.

    Verified against current main (2026-10-28):

    • This repo (dezren39/nix@8c42c2c, origin/main) has no mcpx Go source — pkgs/mcpx/ deleted 2026-10-19 (lab-ops-hub container crawler accepts inet_aton IPv4 spellings (127.1, 2130706433, 0x7f.1) — loopback SSRF bypass in validate_crawl_url #468, planning/plans/mcpx-extraction.md steps 4–5); mcpx lives solely in developing-today/code at pkgs/mcpx/.
    • What landed (in developing-today/code@528886e): the core of Approach C. A zero-build, dependency-free single-page frontend: pkgs/mcpx/internal/dashboard/dashboard.go embeds one 20 KB dashboard.html via //go:embed and the daemon serves it — mux.HandleFunc("GET /dashboard", …) and GET /v1/dashboard at pkgs/mcpx/internal/daemon/server.go:642-643, handler in routes_dashboard.go, an ops-table entry ("Live web dashboard HTML", internal/api/ops.go:594), a mcpx dashboard CLI command, and a handler test (routes_dashboard_test.go). Its data is /v1-only (/v1/status, /v1/metrics/tokens, /v1/metrics/tools, /v1/search, /v1/feedback, /v1/interactions), as Approach C requires. No second build system was added.
    • What did not land (the remainder): git grep "daemon.ui|uiDir" pkgs/mcpx at code HEAD returns nothing — there is no daemon.ui default-off setting and no daemon.uiDir directory escape hatch (both are explicit Approach C decisions); the route is /dashboard, not the proposed /ui/; and the page's scope is pools + token/tool metrics telemetry, not the TUI's six views (browse/log/stats/servers/sessions/storage) read-only plus elicitation forms.
    • Re-homing of the remainder is tracked by mcpx: re-home the open mcpx backlog from dezren39/nix, with this repo as the single source #309 ("mcpx: re-home the live backlog from dezren39/nix", OPEN) and extraction plan steps 3/6. Parent: fix(id): satisfy Rustfmt module ordering #174.

    Route: no small_fix packet in the nix queue, and do not close this issue as fixed — the shipped dashboard satisfies the "serve a zero-build embedded UI" headline but not the setting/gating, escape-hatch, or six-view scope decisions. Keep open here until code#309 re-files the remainder in developing-today/code, then close here with that link (crediting the dashboard that already shipped).


    agent-note:v1 · agent: repo-queue-worker-shift-nix (lane A) · task 2084 · kind: triage · outcome: partially implemented in code (embedded dashboard at /dashboard), remainder (daemon.ui/uiDir, six views + elicitation) confirmed open; routed to #309; no nix follow-up packet

  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  5. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

    Triage verdict — core already built at main 42796be5; rescope the rest

    The issue's Current State (written against the nix-era tree) says "nothing serves static content — there is no http.FileServer and no / handler", and Approach C recommends a zero-build frontend go:embeded into the binary. That mechanism now exists at HEAD:

    • pkgs/mcpx/internal/dashboard/dashboard.go — //go:embed dashboard.html, a "web dashboard UI" handler; the page is a ~20 KB single-file app titled "mcpx Dashboard" (metrics-centric: Tokens / Tools / Metrics, some servers/sessions).
    • pkgs/mcpx/internal/daemon/routes_dashboard.go — serves it at /dashboard, alongside metrics handlers.
    • pkgs/mcpx/internal/cli/dashboard.go — mcpx dashboard [--open] ensures the daemon and prints/opens the /dashboard URL.
    • And indeed no http.FileServer exists under pkgs/mcpx outside e2e tests — the embedded route is the only static serving, in the exact no-second-build-system shape the issue prescribes.

    What remains unbuilt (the rescoped scope):

    1. No gating setting. internal/settings/registry.go daemon.* entries are reapInterval / saveInterval / port / endpoint / address / inline / autostart — daemon.ui has zero hits in repo code. The dashboard is always served; the issue's "off by default so a daemon bound beyond loopback does not silently gain a UI" decision was never taken.
    2. No daemon.uiDir escape hatch — an external directory cannot replace the embedded assets (the dev loop / external-SPA path).
    3. View coverage. The embedded page is a metrics dashboard. The issue's v1 scope — the TUI's six views (browse/log/stats/servers/sessions/storage) read-only plus elicitation answering — is not covered: no elicitation in the page, no browse/log/storage views.
    4. The /ui/ vs / question is settled by the existing /dashboard route — keep it.

    Dedupe — no duplicate issue. Searches for "web frontend", daemon.ui, uiDir return only #1882 itself and the surfaces tracker #1887 (ssh + browser surfaces parent). #1876 (docs runnable example) is a different surface — it consumes the daemon from the docs site and its stage-3 local mode will want the CORS/port decisions noted there, not this UI.

    Follow-up enqueued: queue task 3115 (small_fix, dedupe repo-health:v1:fix:developing-today/code:issue:1882), rescoped to the three gaps above — daemon.ui gating (default to be settled deliberately on the PR, weighing the issue's loopback argument against today's always-on mcpx dashboard behaviour), daemon.uiDir override, and extending the embedded zero-build page to the six TUI views + elicitation, /v1-only. It explicitly excludes rebuilding the frontend or any bundled SPA.

  6. added
    queue/queuedHas a queued task in the Supabase agent queue
    and removed on Oct 29, 2026
  7. 24 remaining items

  8. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  9. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  10. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Oct 30, 2026
  11. added a commit that references this issue on Oct 30, 2026
  12. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done — merged

    • Agent: openchamber scheduled lane (queue worker repo-worker-code)
    • Session / ID: queue task 3115 (small_fix, daemon web frontend) — resumed from awaiting_ci, completed fixed_merged
    • Shipped: PR feat(mcpx): daemon.ui gating and daemon.uiDir override for /dashboard (#1882) #2521 merged to main as 7d49db36a7715fa3334f52ac7ed152663f246720 at 2026-10-30T04:30:58Z (head 0af8d077); verified as an ancestor of origin/main (674c4c6a) at close time.
    • What landed: internal/daemon/routes_dashboard.go (+97) serves the dashboard behind the daemon.ui setting with a daemon.uiDir override; internal/dashboard/dashboard.html (+345) is the embedded page (no second build system); internal/settings/registry.go (+26) declares the two settings; server.go wires the route; routes_dashboard_test.go (+158) covers the gating, the override and the TUI-view/elicitation coverage; docs/configuration.md documents both keys.
    • Check state at merge: gh pr merge put it through the main merge queue and it landed; the PR-head rollup still showed the three basic jobs as QUEUED at the moment I read it, i.e. the queue accepted on its own merge-group validation rather than a completed PR-head run. No held / user/owned / do-not-merge labels.
    • Closed as: fixed by merge.
    • Links: feat(mcpx): daemon.ui gating and daemon.uiDir override for /dashboard (#1882) #2521 · 7d49db3
  13. added and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions