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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 7f84be7001
commit 7fb78c2de6
5 changed files with 152 additions and 12 deletions
+28 -4
View File
@@ -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) */
+1 -1
View File
@@ -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}};
</script>
<script>
{{SCRIPT}}
+21 -2
View File
@@ -1,6 +1,25 @@
const iframe = document.getElementById('drawio');
// Every API request carries the token the server wrote into this page
const api = (url, init = {}) => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
// Every API request carries the token the server wrote into this page. A 403
// means another MCP process (with its own token) now answers on this port:
// read its token from a fresh copy of this page and retry once, so the next
// poll can recover the session instead of being refused forever.
let tokenRefresh = null;
function refreshToken() {
if (!tokenRefresh) {
tokenRefresh = fetch(location.href, { cache: 'no-store' })
.then((r) => r.text())
.then((html) => { const m = html.match(/API_TOKEN = "([0-9a-f]+)"/); if (m) API_TOKEN = m[1]; })
.catch(() => {})
.finally(() => { tokenRefresh = null; });
}
return tokenRefresh;
}
async function api(url, init = {}) {
const send = () => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
let r = await send();
if (r.status === 403) { await refreshToken(); r = await send(); }
return r;
}
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
// The server state this tab is in step with (see stateId in http-server.ts);
// null until the first poll