Give an MCP server an interactive UI. The UI is a React app built to one inlined HTML file with @nimblebrain/synapse + Vite, served by the server as the MCP resource ui://<name>/main, and mounted by the NimbleBrain host in a sandboxed iframe wired to a postMessage bridge. The UI calls the server's existing tools over that bridge โ it is data-layer-agnostic and needs no special server framework.
-
Analyze the server โ language (Python/FastMCP or TS), transport (stdio vs HTTP-native/edge-fronted), the tool list + return shapes, and deploy shape (.mcpb bundle vs a container image โ the container needs a Node build stage, step 7).
-
Scaffold ui/ โ package.json (react/react-dom ^19, @nimblebrain/synapse@^0.11.0, vite, vite-plugin-singlefile, typescript; add marked + dompurify only if you render markdown), vite.config.ts (react(), viteSingleFile(), synapseVite(), build.assetsInlineLimit: Infinity), a strict tsconfig.json, index.html, and .gitignore (node_modules/, dist/, .vite/). Commit package-lock.json so the build can npm ci.
-
Build App.tsx โ <SynapseProvider name="<server>">. The two side-effect imports go in the Vite entry (main.tsx): import "@nimblebrain/synapse/ui/fonts" (brand fonts) and import "@nimblebrain/synapse/ui/base" (the root-height chain AppFrame fills โ applied before first paint; gotcha M). Use the package's AppFrame shell (with AppFrame.Body bleed hosting ListDetailLayout/SidebarLayout) โ never a hand-rolled height:100vh (gotcha D). Drive data with a thin useCall<T>() wrapper around useSynapse().callTool(name, args); refresh with useDataSync; theme with tokens/useTheme; push agent context with useVisibleState. Master lists use ListRow, not Table (gotcha D โ a Table overflows a fixed-width rail and paints over the detail pane).
-
Sanitize any rendered HTML โ do not skip (stored-XSS). If you render server- or agent-authored markdown (notes, descriptions, research output) via Prose / dangerouslySetInnerHTML, run it through DOMPurify first: DOMPurify.sanitize(marked.parse(md, { async: false }) as string). The iframe runs with allow-scripts and a script-src 'unsafe-inline' CSP, so sanitization โ not the CSP โ is the only thing stopping an injected <script>/onerror from running with full tool-bridge authority (read/exfiltrate/mutate everything the tools can reach). Plain <Text>{value}</Text> is safe (React escapes). Full chain: gotcha K.
-
Serve the UI as a resource โ @mcp.resource("ui://<name>/main", mime_type="text/html") returning the built ui/dist/index.html. Resolve the path via an env var (<APP>_UI_DIR) with a __file__-relative fallback (gotcha E โ an installed package lands in site-packages, so a __file__-relative ui/dist lookup misses). Make it a bare file read: no DB/auth session, identity-free HTML, all tenant data fetched at runtime through the bridge.
-
Declare the host placement โ add _meta["ai.nimblebrain/host"] to manifest.json: one placements[] entry (slot: "sidebar.apps", resourceUri: "ui://<name>/main", route, label, icon). Full contract + options: references/host-contract.md.
-
Container-deployed servers โ multi-stage build. A .mcpb bundle gets ui/dist from release CI; a container image must build it: a node:22 builder stage runs npm ci && npm run build, then the runtime COPY --from=builder โฆ/ui/dist (so the runtime stays Node-free) and sets the UI-dir env var. Strip the ui/ source from the runtime layer โ only dist ships.
-
Local preview โ cd ui && npm run dev โ open /__preview. For an edge-fronted server (identity from HTTP headers + a DB), the real server can't be driven over the stdio preview, so every call returns unauthenticated โ point synapseVite({ serverCmd }) at a seeded stdio mock behind an env flag (gotcha F).
-
Verify โ npx tsc --noEmit and npm run build (Vite/esbuild won't type-check on its own). Then run the server project's own lint/format/test gate (e.g. make verify, ruff format --check), not just the UI type-check โ a format-only diff will redden CI even when types pass. Toggle the preview to dark and light and eyeball every surface โ the token-fallback trap (gotcha L) passes tsc/build, looks fine in light, and only shows as white-on-white in dark. Confirm the server serves ui://<name>/main as text/html.