diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts
index f24ef57f..48a49864 100644
--- a/packages/mcp-server/src/http-server.ts
+++ b/packages/mcp-server/src/http-server.ts
@@ -80,10 +80,27 @@ let drawioDir: string | null =
[join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) =>
existsSync(join(dir, "index.html")),
) ?? null
+let drawioVersion = readDrawioVersion(drawioDir)
/** For tests: serve draw.io from this directory (null: no bundled copy) */
export function setDrawioDir(dir: string | null): void {
drawioDir = dir
+ drawioVersion = readDrawioVersion(dir)
+}
+
+/**
+ * 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
+ * not) would otherwise hand out the old ETag for a changed file of the
+ * same size after an upgrade.
+ */
+function readDrawioVersion(dir: string | null): string {
+ if (!dir) return ""
+ try {
+ return readFileSync(join(dir, ".version"), "utf8").trim()
+ } catch {
+ return ""
+ }
}
/**
@@ -184,7 +201,7 @@ interface SessionState {
exportFormat?: ExportFormat // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
- exportId?: number // Number of the pending export, echoed with its result
+ exportId?: string // Random id of the pending export, echoed with its result
exportData?: string // Base64/SVG data returned by browser after export
}
@@ -305,13 +322,13 @@ export function requestExport(
state.exportOptions = options
state.exportFormat = format
// The browser sends this back with the result, so a late result of an
- // export that timed out is not taken for this one
- state.exportId = ++lastExportId
+ // export that timed out is not taken for this one. Random rather than
+ // counted: a counter restarts with the process, and the page retries a
+ // result refused with 403 against the process that took over the port
+ state.exportId = randomUUID()
return true
}
-let lastExportId = 0
-
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
@@ -922,7 +939,7 @@ function serveDrawioFile(
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)}"`
+ etag = `"${drawioVersion}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
} catch {
res.writeHead(404)
res.end("Not Found")
diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts
index fb29f883..c534fa7c 100644
--- a/packages/mcp-server/tests/http-server.test.ts
+++ b/packages/mcp-server/tests/http-server.test.ts
@@ -6,7 +6,13 @@
* node:http so tests can set raw paths and Host/Origin headers.
*/
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import {
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs"
import http from "node:http"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -43,6 +49,7 @@ beforeAll(async () => {
mkdirSync(join(drawioDir, "js"))
mkdirSync(join(drawioDir, "WEB-INF"))
writeFileSync(join(drawioDir, "index.html"), "draw.io")
+ writeFileSync(join(drawioDir, ".version"), "v1\n")
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "")
setDrawioDir(drawioDir)
@@ -382,6 +389,25 @@ describe("export requests", () => {
expect(getState(id)?.exportData).toBeUndefined()
expect(getState(id)?.exportFormat).toBe("svg")
})
+
+ it("names exports at random, so a result meant for another process is not taken", async () => {
+ const id = "mcp-export-random-id"
+ setState(id, "x")
+ requestExport(id, "svg")
+ const poll = JSON.parse(
+ (await request(`/api/state?sessionId=${id}`)).body,
+ )
+ expect(poll.exportId).toMatch(/^[0-9a-f-]{36}$/)
+ // The page retries a result refused with 403 against the process
+ // that took over the port; a per-process counter would restart at 1
+ await postJson("/api/state", {
+ sessionId: id,
+ exportData: "",
+ exportId: 1,
+ })
+ expect(getState(id)?.exportData).toBeUndefined()
+ expect(getState(id)?.exportFormat).toBe("svg")
+ })
})
describe("a session state recreated after it was lost", () => {
@@ -571,11 +597,37 @@ describe("bundled draw.io files", () => {
// Revalidated on every use: the file names stay the same across
// draw.io versions, so a package upgrade must reach the browser
expect(res.headers["cache-control"]).toBe("no-cache")
- expect(res.headers.etag).toMatch(/^"[0-9a-f]+-[0-9a-f]+"$/)
+ expect(res.headers.etag).toMatch(/^"v1-[0-9a-f]+-[0-9a-f]+"$/)
expect(res.headers["x-content-type-options"]).toBe("nosniff")
expect(res.headers["content-security-policy"]).toBeUndefined()
})
+ it("changes the ETag with the draw.io version, whatever the file dates say", async () => {
+ // An install that keeps the archive's dates gives the upgraded file
+ // the same date and, here, the same size as before
+ const file = join(drawioDir, "js/same-size.js")
+ const date = new Date("2020-01-01T00:00:00Z")
+ writeFileSync(file, "// v1")
+ utimesSync(file, date, date)
+ const first = await request("/drawio/js/same-size.js")
+ writeFileSync(file, "// v2")
+ utimesSync(file, date, date)
+ writeFileSync(join(drawioDir, ".version"), "v2\n")
+ setDrawioDir(drawioDir)
+ try {
+ const again = await request("/drawio/js/same-size.js", {
+ headers: { "if-none-match": first.headers.etag as string },
+ })
+ expect(again.status).toBe(200)
+ expect(again.body).toBe("// v2")
+ expect(again.headers.etag).not.toBe(first.headers.etag)
+ } finally {
+ writeFileSync(join(drawioDir, ".version"), "v1\n")
+ setDrawioDir(drawioDir)
+ rmSync(file, { force: true })
+ }
+ })
+
it("answers 304 to a matching If-None-Match, and HEAD without a body", async () => {
const first = await request("/drawio/js/app.min.js")
const again = await request("/drawio/js/app.min.js", {