From b6f42bcddc6086fa5d8df06cdbce14aa7bf9fb98 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 13:04:44 +0900 Subject: [PATCH] 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/ 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. --- packages/mcp-server/README.md | 1 + packages/mcp-server/src/http-server.ts | 256 ++++++++++++++---- packages/mcp-server/src/index.ts | 3 +- packages/mcp-server/tests/http-server.test.ts | 168 ++++++++++++ 4 files changed, 372 insertions(+), 56 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index ae58d60b..6db288a0 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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. | diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 48a49864..89f95b54 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -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=; 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 = { ".wasm": "application/wasm", } -/** GET /drawio/: a file of the bundled draw.io copy */ -function serveDrawioFile( +/** + * GET /drawio/ or /shell/: 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 ", + ) + 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 = "" + try { + const res = await request("/shell/?mcp=mcp-shell-escape") + expect(res.body).not.toContain("