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: "OLD", + 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", {