feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild

The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 7e2edbf8a1
commit 77c6a2d807
19 changed files with 585 additions and 1 deletions
@@ -0,0 +1,90 @@
import { Moon, Sun } from "lucide-react"
import { useMemo } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import { CompareDialog } from "@/components/canvas/compare-dialog"
import { VersionStrip } from "@/components/canvas/version-strip"
import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import type { DrawioSource } from "@/lib/drawio/drawio-config"
import { useSettingsStore } from "@/stores/settings-store"
import type { ShellConfig } from "./runtime-config"
import { setDark } from "./theme"
// No versions until the shell reads the server's History (a later step)
const NO_VERSIONS: VersionsSource = {
versions: [],
onCanvasId: null,
undoneId: null,
isBusy: false,
canUndo: false,
canRedo: false,
restore: () => {},
undo: () => {},
redo: () => {},
}
/**
* The MCP preview: the web app's canvas without the chat. A slim bar names
* the session and the sync state; the versions strip and Compare come from
* the shared canvas components.
*/
export function CanvasShell({ config }: { config: ShellConfig }) {
const dict = useDictionary()
const isDark = useSettingsStore((s) => s.isDark)
const drawioSource = useMemo<DrawioSource>(
() => ({
editorUrl: config.drawioBaseUrl,
ui: config.drawioUi || undefined,
lang: config.drawioLang || undefined,
}),
[config],
)
return (
<VersionsProvider value={NO_VERSIONS}>
<div className="flex h-dvh flex-col bg-background text-foreground">
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
<span className="text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
{config.sessionId && (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
title={config.sessionId}
data-testid="shell-session"
>
{config.sessionId.slice(-8)}
</span>
)}
<span
className="truncate text-xs text-muted-foreground"
data-testid="shell-status"
>
{dict.shell.waiting}
</span>
<button
type="button"
onClick={() => setDark(!isDark)}
aria-label={dict.shell.toggleTheme}
title={dict.shell.toggleTheme}
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
data-testid="theme-toggle"
>
{isDark ? <Sun /> : <Moon />}
</button>
</header>
<VersionStrip minVersions={1} />
<div className="relative flex-1">
<CanvasStage
className="inset-0"
drawioSource={drawioSource}
/>
</div>
<CompareDialog />
</div>
</VersionsProvider>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { i18n, type Locale } from "@/lib/i18n/config"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import en from "@/lib/i18n/dictionaries/en.json"
import ja from "@/lib/i18n/dictionaries/ja.json"
import zh from "@/lib/i18n/dictionaries/zh.json"
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
// is server-only); the shell ships all four and picks in the browser
const DICTIONARIES: Record<Locale, Dictionary> = {
en,
zh,
ja,
"zh-Hant": zhHant,
}
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
export function matchLocale(tag: string): Locale | null {
const lower = tag.trim().toLowerCase()
if (!lower) return null
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
const base = lower.split("-")[0]
if (base === "zh") return "zh"
if (base === "ja") return "ja"
if (base === "en") return "en"
return null
}
/**
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
* else the browser's first language we have, else English.
*/
export function pickLocale(
configLang: string,
browserLangs: readonly string[],
): Locale {
for (const tag of [configLang, ...browserLangs]) {
const locale = matchLocale(tag)
if (locale) return locale
}
return i18n.defaultLocale
}
export function getShellDictionary(locale: Locale): Dictionary {
return DICTIONARIES[locale]
}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Next AI Draw.io</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/shell/shell.css">
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
<script>
// Dark class before the first paint (no white flash). The same rule
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
// the server's DRAWIO_DARK, else the system
try {
const config = window.__MCP_CONFIG__ || {}
const stored = localStorage.getItem("mcp:dark-mode")
const dark =
stored === "true" ||
(stored === null &&
(config.drawioDark === "dark" ||
(config.drawioDark !== "light" &&
matchMedia("(prefers-color-scheme: dark)").matches)))
if (dark) document.documentElement.classList.add("dark")
} catch {}
</script>
</head>
<body class="antialiased">
<div id="root"></div>
<script type="module" src="/shell/shell.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
// dist/shell/shell.js and served at /shell/ by http-server.ts
import { createRoot } from "react-dom/client"
import { LocaleProvider } from "@/components/canvas/locale-context"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import { CanvasShell } from "./canvas-shell"
import { getShellDictionary, pickLocale } from "./dictionary"
import { pageShellConfig } from "./runtime-config"
import { initTheme } from "./theme"
const config = pageShellConfig()
const locale = pickLocale(config.lang, navigator.languages)
// The stylesheet picks the CJK fonts by the document's language
document.documentElement.lang = locale
initTheme(config.drawioDark)
const root = document.getElementById("root")
if (!root) throw new Error("The shell page has no #root element")
createRoot(root).render(
<DictionaryProvider dictionary={getShellDictionary(locale)}>
<LocaleProvider locale={locale}>
<DiagramProvider>
<CanvasShell config={config} />
</DiagramProvider>
</LocaleProvider>
</DictionaryProvider>,
)
@@ -0,0 +1,64 @@
/**
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
*/
export interface ShellConfig {
sessionId: string
/** Every /api request carries it in the X-Drawio-Token header */
token: string
/** Prefix of the server's API paths */
apiBase: string
/** The editor page: the bundled copy's index.html on this origin, or an
* external draw.io */
drawioBaseUrl: string
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
drawioUi: string
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
* the pick to draw.io */
drawioLang: string
/** DRAWIO_DARK: the theme when the user has not chosen one here */
drawioDark: "dark" | "light" | "auto"
/** DRAWIO_LANG as given, for the shell's own texts */
lang: string
}
const DEFAULTS: ShellConfig = {
sessionId: "",
token: "",
apiBase: "/api",
drawioBaseUrl: "/drawio/index.html",
drawioUi: "",
drawioLang: "",
drawioDark: "auto",
lang: "",
}
/**
* The page's config with every field present: a missing or malformed one
* (another server version) takes its default.
*/
export function readShellConfig(source: unknown): ShellConfig {
const given =
source && typeof source === "object"
? (source as Record<string, unknown>)
: {}
const config = { ...DEFAULTS }
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
const value = given[key]
if (key === "drawioDark") {
if (value === "dark" || value === "light" || value === "auto") {
config.drawioDark = value
}
} else if (typeof value === "string") {
config[key] = value
}
}
return config
}
/** The config the server wrote into this page */
export function pageShellConfig(): ShellConfig {
return readShellConfig(
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
)
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
* with the utility classes of the shared components the shell bundles.
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
* own, and the shared components through the @source lines.
*/
@import "../../../app/globals.css";
@source "../../../components/canvas";
@source "../../../components/ui";
@source "../../../components/ai-elements";
/* System fonts stand in for the web fonts the Next.js layout loads */
:root {
--font-latin:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
+61
View File
@@ -0,0 +1,61 @@
import { useSettingsStore } from "@/stores/settings-store"
import type { ShellConfig } from "./runtime-config"
/**
* localStorage key of the user's theme choice here: "true" dark, "false"
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
* so a web app dev server on the same port keeps its own settings.
* index.html repeats the key in its inline script (run before this bundle).
*/
export const THEME_STORAGE_KEY = "mcp:dark-mode"
/** Whether the shell is dark, from the choice stored here, the server's
* setting and the system */
export function isDarkPreferred(
stored: string | null,
drawioDark: ShellConfig["drawioDark"],
systemDark: boolean,
): boolean {
if (stored === "true" || stored === "false") return stored === "true"
if (drawioDark === "auto") return systemDark
return drawioDark === "dark"
}
function read(): string | null {
try {
return localStorage.getItem(THEME_STORAGE_KEY)
} catch {
return null
}
}
// The canvas components read the settings store's isDark (and the theme
// class, set before the first paint by index.html)
function apply(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark)
useSettingsStore.setState({
hydrated: true,
isDark,
theme: isDark ? "dark" : "light",
})
}
/** Take the theme the inline script chose, and follow the system while
* nothing else decides */
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
const query = window.matchMedia("(prefers-color-scheme: dark)")
apply(isDarkPreferred(read(), drawioDark, query.matches))
query.addEventListener("change", (event) => {
apply(isDarkPreferred(read(), drawioDark, event.matches))
})
}
/** The user's choice, kept for the next visit */
export function setDark(isDark: boolean) {
try {
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
} catch {
// Storage can be unavailable (private mode, quota)
}
apply(isDark)
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"incremental": false,
"plugins": []
},
"include": ["./**/*.ts", "./**/*.tsx"]
}