feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI

GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 77c6a2d807
commit b6f42bcddc
4 changed files with 372 additions and 56 deletions
+1
View File
@@ -188,6 +188,7 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct
| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
| `DRAWIO_PREVIEW_UI` | `classic` | The preview page `start_session` opens. `shell` opens the new canvas page built from the web app's canvas (in progress: it does not sync with the server yet). |
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
+201 -55
View File
@@ -88,6 +88,44 @@ export function setDrawioDir(dir: string | null): void {
drawioVersion = readDrawioVersion(dir)
}
// The canvas shell (the web app's canvas without the chat), built by
// scripts/build-shell.mjs into dist/shell
let shellDir: string | null =
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
existsSync(join(dir, "index.html")),
) ?? null
/** For tests: serve the shell from this directory (null: not built) */
export function setShellDir(dir: string | null): void {
shellDir = dir
}
// This package's version, stamped into the shell files' ETags (see
// readDrawioVersion for why)
const PACKAGE_VERSION: string = JSON.parse(
readFileSync(join(HERE, "../package.json"), "utf8"),
).version
/**
* The preview page start_session opens: the classic page (src/preview), or
* the canvas shell with DRAWIO_PREVIEW_UI=shell. The shell becomes the
* default once it does everything the classic page does.
*/
export type PreviewUi = "classic" | "shell"
export const PREVIEW_UI: PreviewUi =
(process.env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "shell"
? "shell"
: "classic"
export function previewUrl(
port: number,
sessionId: string,
ui: PreviewUi = PREVIEW_UI,
): string {
const path = ui === "shell" ? "/shell/" : ""
return `http://localhost:${port}${path}?mcp=${sessionId}`
}
/**
* The draw.io version the fetch script stamps into the copy. It is part of
* every file's ETag: an install that keeps the archive's dates (npm does
@@ -495,7 +533,28 @@ function routeRequest(
}
if (url.pathname.startsWith("/drawio/")) {
serveDrawioFile(req, res, url.pathname.slice("/drawio/".length))
serveStaticFile(
req,
res,
drawioDir,
url.pathname.slice("/drawio/".length),
drawioVersion,
)
return
}
if (url.pathname === "/shell") {
res.writeHead(302, { Location: `/shell/${url.search}` })
res.end()
return
}
if (url.pathname.startsWith("/shell/")) {
const rest = url.pathname.slice("/shell/".length)
if (rest === "" || rest === "index.html") {
servePage(req, res, url, "/shell/", getShellPage)
} else {
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
}
return
}
@@ -509,34 +568,7 @@ function routeRequest(
}
if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) {
res.writeHead(400)
res.end("Invalid session id")
return
}
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, {
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
})
res.end()
return
}
}
ensureSessionStateInitialized(sessionId)
// The page holds this process's token: never served from a cache
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
...HTML_SECURITY_HEADERS,
})
res.end(getHtmlPage(sessionId))
servePage(req, res, url, "/", getHtmlPage)
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") {
@@ -551,6 +583,60 @@ function routeRequest(
}
}
/**
* A preview page (the classic one at "/", the shell at "/shell/") for the
* session in ?mcp=<id>; without one, the most recent session's.
*/
function servePage(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
pagePath: string,
render: (sessionId: string) => string | null,
): void {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) {
res.writeHead(400)
res.end("Invalid session id")
return
}
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, {
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
})
res.end()
return
}
}
const html = render(sessionId)
if (html === null) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
res.end(
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
)
return
}
ensureSessionStateInitialized(sessionId)
// The page holds this process's token: never served from a cache
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
...HTML_SECURITY_HEADERS,
})
res.end(req.method === "HEAD" ? undefined : html)
}
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -899,11 +985,16 @@ const MIME_TYPES: Record<string, string> = {
".wasm": "application/wasm",
}
/** GET /drawio/<path>: a file of the bundled draw.io copy */
function serveDrawioFile(
/**
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
* of the built shell. `stamp` names the files' version in their ETags.
*/
function serveStaticFile(
req: http.IncomingMessage,
res: http.ServerResponse,
dir: string | null,
rawPath: string,
stamp: string,
): void {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405)
@@ -921,13 +1012,13 @@ function serveDrawioFile(
// One normalized path inside the directory; the war's server-side parts
// are never served, whatever was extracted
const normalized = posix.normalize(rel)
const file = drawioDir ? resolve(drawioDir, normalized) : null
const file = dir ? resolve(dir, normalized) : null
if (
!drawioDir ||
!dir ||
!file ||
/[\\\0]/.test(rel) ||
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
!file.startsWith(drawioDir + sep)
!file.startsWith(dir + sep)
) {
res.writeHead(404)
res.end("Not Found")
@@ -939,7 +1030,7 @@ function serveDrawioFile(
const stat = statSync(file)
if (!stat.isFile()) throw new Error("not a file")
size = stat.size
etag = `"${drawioVersion}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
} catch {
res.writeHead(404)
res.end("Not Found")
@@ -997,37 +1088,92 @@ function loadPreviewTemplate(): string {
return previewTemplate
}
/** A JSON string literal that is safe inside a <script> element */
const scriptJson = (value: string) =>
/** JSON that is safe inside a <script> element ("<" escaped) */
const scriptJson = (value: unknown) =>
JSON.stringify(value).replace(/</g, "\\u003c")
/**
* The configurable tail of the draw.io iframe query: dark mode, and the
* language and theme when the host config fixes them (DRAWIO_DARK,
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
* draw.io hides its Extras > Language / Theme submenu once they are given.
* The editor settings the host config fixes: theme (DRAWIO_UI), language
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
* because draw.io hides its Extras > Language / Theme submenu once they
* are given.
*/
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
ui: string
lang: string
dark: "dark" | "light" | "auto"
} {
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
return {
ui: isDrawioTheme(ui) ? ui : "",
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
dark: ["1", "true", "dark"].includes(dark)
? "dark"
: ["0", "false", "light"].includes(dark)
? "light"
: // draw.io takes ui=dark as dark mode only when no dark
// parameter is present, and the pages always send one
!dark && ui === "dark"
? "dark"
: "auto",
}
}
/**
* The configurable tail of the classic page's draw.io iframe query: dark
* mode, and the language and theme when the host config fixes them.
*/
export function drawioEmbedParams(
env: NodeJS.ProcessEnv = process.env,
): string {
const settings = hostDrawioSettings(env)
const params = new URLSearchParams()
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
if (["1", "true", "dark"].includes(dark)) {
params.set("dark", "1")
} else if (["0", "false", "light"].includes(dark)) {
params.set("dark", "0")
} else {
// draw.io takes ui=dark as dark mode only when no dark parameter is
// present, and this page always sends one
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
}
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
if (isDrawioTheme(ui)) params.set("ui", ui)
params.set(
"dark",
settings.dark === "auto"
? "auto"
: settings.dark === "dark"
? "1"
: "0",
)
if (settings.lang) params.set("lang", settings.lang)
if (settings.ui) params.set("ui", settings.ui)
return params.toString()
}
/**
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
* reads it): the session, the API token, where draw.io comes from and the
* host's editor settings.
*/
export function shellConfig(
sessionId: string,
env: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const settings = hostDrawioSettings(env)
return {
sessionId,
token: API_TOKEN,
apiBase: "/api",
drawioBaseUrl: drawioEditorUrl(),
drawioUi: settings.ui,
drawioLang: settings.lang,
drawioDark: settings.dark,
lang: env.DRAWIO_LANG ?? "",
}
}
/** The shell page, or null when the shell is not built */
function getShellPage(sessionId: string): string | null {
if (!shellDir) return null
const template = readFileSync(join(shellDir, "index.html"), "utf8")
return template.replace("{{CONFIG_JSON}}", () =>
scriptJson(shellConfig(sessionId)),
)
}
function getHtmlPage(sessionId: string): string {
return (
loadPreviewTemplate()
+2 -1
View File
@@ -57,6 +57,7 @@ import {
keepInHistory,
onSessionRecreate,
onStateChange,
previewUrl,
requestExport,
requestSync,
restoreHistoryEntry,
@@ -382,7 +383,7 @@ registerWriteTool(
}
// Open browser
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
const browserUrl = previewUrl(port, sessionId)
await open(browserUrl)
// A saved diagram comes back from its file. lastSeenXml stays
@@ -27,11 +27,14 @@ import {
keepInHistory,
onSessionRecreate,
onStateChange,
previewUrl,
requestExport,
requestSync,
restoreHistoryEntry,
setDrawioDir,
setShellDir,
setState,
shellConfig,
shutdown,
startHttpServer,
waitForSync,
@@ -41,6 +44,8 @@ let port = 0
// A stand-in for the bundled draw.io copy (dist/drawio), with a server-side
// part of the war that must never be served
let drawioDir = ""
// A stand-in for the built canvas shell (dist/shell)
let shellDir = ""
beforeAll(async () => {
// XML parsing, as the server installs it at startup
@@ -53,12 +58,21 @@ beforeAll(async () => {
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "<web-app/>")
setDrawioDir(drawioDir)
shellDir = mkdtempSync(join(tmpdir(), "shell-static-"))
writeFileSync(
join(shellDir, "index.html"),
"<html><script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script></html>",
)
writeFileSync(join(shellDir, "shell.js"), "// shell")
writeFileSync(join(shellDir, "shell.css"), "body{}")
setShellDir(shellDir)
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
})
afterAll(() => {
shutdown()
rmSync(drawioDir, { recursive: true, force: true })
rmSync(shellDir, { recursive: true, force: true })
})
interface Response {
@@ -705,6 +719,160 @@ describe("bundled draw.io files", () => {
})
})
describe("canvas shell page", () => {
/** The config the served page carries */
const configOf = (body: string) => {
const match = body.match(
/window\.__MCP_CONFIG__ = (\{.*?\});<\/script>/s,
)
if (!match) throw new Error(`no config in ${body}`)
return JSON.parse(match[1])
}
it("writes the session, the token and where draw.io comes from into the page", async () => {
const res = await request("/shell/?mcp=mcp-shell-page")
expect(res.status).toBe(200)
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
expect(res.body).not.toContain("{{")
expect(configOf(res.body)).toEqual({
sessionId: "mcp-shell-page",
token: getApiToken(),
apiBase: "/api",
drawioBaseUrl: "/drawio/index.html",
drawioUi: "",
drawioLang: "",
drawioDark: "auto",
lang: "",
})
// The page creates the session, as the classic page does
expect(getState("mcp-shell-page")).toBeDefined()
// The same page at its index.html name
const index = await request("/shell/index.html?mcp=mcp-shell-page")
expect(index.status).toBe(200)
expect(configOf(index.body).sessionId).toBe("mcp-shell-page")
})
it("passes the host's editor settings on, in the shell's own terms", () => {
const config = shellConfig("mcp-s", {
DRAWIO_UI: "Sketch",
DRAWIO_LANG: "zh-Hant",
DRAWIO_DARK: "1",
})
expect(config.drawioUi).toBe("sketch")
expect(config.drawioLang).toBe("zh-tw")
expect(config.drawioDark).toBe("dark")
// The language as given, for the shell's own texts
expect(config.lang).toBe("zh-Hant")
expect(shellConfig("mcp-s", { DRAWIO_DARK: "light" }).drawioDark).toBe(
"light",
)
expect(shellConfig("mcp-s", { DRAWIO_UI: "dark" }).drawioDark).toBe(
"dark",
)
expect(shellConfig("mcp-s", { DRAWIO_UI: "neon" }).drawioUi).toBe("")
})
it("names the external draw.io when there is no bundled copy", async () => {
setDrawioDir(null)
try {
const res = await request("/shell/?mcp=mcp-shell-external")
expect(configOf(res.body).drawioBaseUrl).toBe(
"https://embed.diagrams.net/",
)
} finally {
setDrawioDir(drawioDir)
}
})
it("escapes a value that could close the script element", async () => {
const before = process.env.DRAWIO_LANG
process.env.DRAWIO_LANG = "</script><script>alert(1)</script>"
try {
const res = await request("/shell/?mcp=mcp-shell-escape")
expect(res.body).not.toContain("</script><script>alert")
expect(res.body).toContain("\\u003c/script>")
expect(configOf(res.body).lang).toBe(process.env.DRAWIO_LANG)
} finally {
if (before === undefined) delete process.env.DRAWIO_LANG
else process.env.DRAWIO_LANG = before
}
})
it("sends the security headers, is never cached and needs no token", async () => {
const res = await request("/shell/?mcp=mcp-shell-headers", {
headers: { "x-drawio-token": "" },
})
expect(res.status).toBe(200)
expect(res.headers["content-security-policy"]).toBe(
"frame-ancestors 'self'",
)
expect(res.headers["x-content-type-options"]).toBe("nosniff")
expect(res.headers["cache-control"]).toBe("no-store")
})
it("rejects a bad session id and goes to the most recent session without one", async () => {
const bad = await request(
`/shell/?mcp=${encodeURIComponent('";alert(1)//')}`,
)
expect(bad.status).toBe(400)
setState("mcp-shell-recent", "<mxfile>recent</mxfile>")
const res = await request("/shell/")
expect(res.status).toBe(302)
// (other tests of this file create sessions in the same millisecond)
expect(res.headers.location).toMatch(/^\/shell\/\?mcp=mcp-/)
// Without the trailing slash the assets would resolve elsewhere
const slash = await request("/shell?mcp=mcp-shell-recent")
expect(slash.status).toBe(302)
expect(slash.headers.location).toBe("/shell/?mcp=mcp-shell-recent")
})
it("serves the shell's files with their MIME types, without a token", async () => {
const js = await request("/shell/shell.js", {
headers: { "x-drawio-token": "" },
})
expect(js.status).toBe(200)
expect(js.body).toBe("// shell")
expect(js.headers["content-type"]).toBe(
"text/javascript; charset=utf-8",
)
expect(js.headers["x-content-type-options"]).toBe("nosniff")
expect(js.headers["cache-control"]).toBe("no-cache")
expect(js.headers.etag).toMatch(/^"\d+\.\d+\.\d+-[0-9a-f]+-[0-9a-f]+"$/)
const css = await request("/shell/shell.css")
expect(css.headers["content-type"]).toBe("text/css; charset=utf-8")
for (const path of [
"/shell/../package.json",
"/shell/%2e%2e/package.json",
"/shell/missing.js",
]) {
const res = await request(path)
expect(res.status, path).toBe(404)
expect(res.body).not.toContain('"name"')
}
})
it("says so when the shell is not built", async () => {
setShellDir(null)
try {
const page = await request("/shell/?mcp=mcp-shell-unbuilt")
expect(page.status).toBe(404)
expect(page.body).toContain("npm run build")
expect((await request("/shell/shell.js")).status).toBe(404)
} finally {
setShellDir(shellDir)
}
})
it("is what start_session opens with DRAWIO_PREVIEW_UI=shell", () => {
expect(previewUrl(6002, "mcp-x", "shell")).toBe(
"http://localhost:6002/shell/?mcp=mcp-x",
)
expect(previewUrl(6002, "mcp-x", "classic")).toBe(
"http://localhost:6002?mcp=mcp-x",
)
})
})
describe("draw.io embed parameters from the host config", () => {
it("follows the system dark mode and fixes nothing else by default", () => {
expect(drawioEmbedParams({})).toBe("dark=auto")