From 7fb78c2de6f9378b3acd6ab98ac4e354c08497a0 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 12:06:12 +0900 Subject: [PATCH] fix(mcp-server): review fixes for the preview token and the draw.io static files - The preview page fetches a fresh copy of itself and retries once when an API request is refused with 403: another MCP process, with its own token, now answers on this port, and the recovery logic (recoverState) needs its polls to go through. The page is sent with Cache-Control: no-store. - draw.io files are served with an ETag and Cache-Control: no-cache instead of a 24 hour max-age: their names do not change between versions, so a package upgrade must reach the browser on the next preview. HEAD and If-None-Match (304) are answered. - The file read stream goes through stream.pipeline, so a read error no longer ends the MCP process and a client that leaves mid-download no longer leaks the file handle. --- packages/mcp-server/src/http-server.ts | 32 ++++++++-- packages/mcp-server/src/preview/index.html | 2 +- packages/mcp-server/src/preview/preview.js | 23 +++++++- packages/mcp-server/tests/http-server.test.ts | 48 ++++++++++++++- tests/unit/mcp-preview-recovery.test.ts | 59 ++++++++++++++++++- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index f77e9d6c..f24ef57f 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -7,6 +7,7 @@ import { randomBytes, randomUUID } from "node:crypto" import { createReadStream, existsSync, readFileSync, statSync } from "node:fs" import http from "node:http" import { dirname, extname, join, posix, resolve, sep } from "node:path" +import { pipeline } from "node:stream" import { fileURLToPath } from "node:url" const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB @@ -512,8 +513,10 @@ function routeRequest( 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)) @@ -885,7 +888,7 @@ function serveDrawioFile( res: http.ServerResponse, rawPath: string, ): void { - if (req.method !== "GET") { + if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405) res.end("Method Not Allowed") return @@ -914,24 +917,45 @@ function serveDrawioFile( return } let size: number + let etag: string try { const stat = statSync(file) if (!stat.isFile()) throw new Error("not a file") size = stat.size + etag = `"${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"` } catch { res.writeHead(404) res.end("Not Found") return } + // The file names do not change between draw.io versions, so the browser + // may keep a copy but asks before using it (a 304 from localhost is + // cheap); after a package upgrade the next preview gets the new files + const cacheHeaders = { + ETag: etag, + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + } + if (req.headers["if-none-match"] === etag) { + res.writeHead(304, cacheHeaders) + res.end() + return + } const ext = extname(file).toLowerCase() res.writeHead(200, { + ...cacheHeaders, "Content-Type": MIME_TYPES[ext] || "application/octet-stream", "Content-Length": size, - "Cache-Control": "public, max-age=86400", - "X-Content-Type-Options": "nosniff", ...(ext === ".html" ? HTML_SECURITY_HEADERS : {}), }) - createReadStream(file).pipe(res) + if (req.method === "HEAD") { + res.end() + return + } + // pipeline closes both ends when either fails or goes away (a tab closed + // mid-download), so no file handle leaks and no error goes unhandled; a + // client that left early is routine, not worth a log line + pipeline(createReadStream(file), res, () => {}) } /** Where the iframe loads the editor from (without its query) */ diff --git a/packages/mcp-server/src/preview/index.html b/packages/mcp-server/src/preview/index.html index e6b85732..5d14780a 100644 --- a/packages/mcp-server/src/preview/index.html +++ b/packages/mcp-server/src/preview/index.html @@ -87,7 +87,7 @@ const sessionId = {{SESSION_JSON}}; // Empty when draw.io is served from this origin const DRAWIO_ORIGIN = {{ORIGIN_JSON}} || location.origin; - const API_TOKEN = {{TOKEN_JSON}}; + let API_TOKEN = {{TOKEN_JSON}}; ', + }) + await t.settle() + // The push is retried with the new token; the new process refuses + // it for its state id, and the poll that follows recovers + const retried = t.next("POST") + expect(retried.headers["X-Drawio-Token"]).toBe("0123abcd") + retried.answer({ + status: 409, + body: { error: "Session was recreated" }, + }) + await t.settle() + const poll = t.next("GET") + expect(poll.headers["X-Drawio-Token"]).toBe("0123abcd") + poll.answer(state("S2", 1, "A")) + await t.settle() + await t.settle() + expect(t.page.read().stateId).toBe("S2") + const push = t.next("POST") + expect(push.headers["X-Drawio-Token"]).toBe("0123abcd") + expect(push.body).toMatchObject({ + xml: "B", + stateId: "S2", + baseVersion: 1, + }) + }) + it("shows the server's diagram and keeps the tab's in History", async () => { const t = await inStep() const poll = t.page.poll()