diff --git a/packages/mcp-server/tests/e2e/shell.spec.ts b/packages/mcp-server/tests/e2e/shell.spec.ts
index 8e971a08..d4558f26 100644
--- a/packages/mcp-server/tests/e2e/shell.spec.ts
+++ b/packages/mcp-server/tests/e2e/shell.spec.ts
@@ -243,6 +243,83 @@ test("exporting another page shows it for a moment, then the user's page comes b
expect(xml).toContain('name="Second"')
})
+test("a screenshot of another page leaves the page on screen alone", async ({
+ page,
+}) => {
+ const frame = await openShell(page)
+ await toolText("create_new_diagram", { xml: box("2", "Front page", 40) })
+ await expect(frame.getByText("Front page")).toBeVisible({ timeout: 10_000 })
+ await toolText("add_page", {
+ name: "Backside",
+ xml: ``,
+ })
+ const tabs = frame.locator(".geTabContainer")
+ await expect(tabs.getByText("Backside", { exact: true })).toBeVisible({
+ timeout: 10_000,
+ })
+ const activeTab = tabs.locator(".geActivePage")
+ await expect(activeTab).toHaveText("Page-1")
+ // The page on screen, as the editor reports it to the server
+ const before = await toolText("get_selection")
+ expect(before).toContain('page "Page-1"')
+
+ // PNG: draw.io renders the other page by its id, off screen; the shell
+ // never shows it as a projection, so the page on screen does not change
+ const png = async (args: Record) => {
+ const result = await client.callTool({
+ name: "screenshot_diagram",
+ arguments: args,
+ })
+ const image = (
+ result.content as {
+ type: string
+ data?: string
+ mimeType?: string
+ }[]
+ ).find((c) => c.type === "image")
+ expect(result.isError ?? false).toBe(false)
+ expect(image?.mimeType).toBe("image/png")
+ expect(image?.data?.length ?? 0).toBeGreaterThan(1000)
+ return image?.data ?? ""
+ }
+ const back = await png({ page_name: "Backside" })
+ await expect(activeTab).toHaveText("Page-1")
+ await expect(frame.getByText("Front page")).toBeVisible()
+ expect(await toolText("get_selection")).toContain('page "Page-1"')
+ // Another picture than the page on screen
+ expect(await png({})).not.toBe(back)
+})
+
+test("get_selection reports the cells selected in the editor", async ({
+ page,
+}) => {
+ const frame = await openShell(page)
+ await toolText("create_new_diagram", {
+ xml: `${box("2", "Pick me", 40)}\n${box("3", "Leave me", 240)}\n`,
+ })
+ await expect(frame.getByText("Pick me")).toBeVisible({ timeout: 10_000 })
+ const nothing = await toolText("get_selection")
+ expect(nothing).toContain("Nothing is selected")
+ expect(nothing).toContain('page "Page-1"')
+
+ // Select a shape and the edge in the editor, as a click would
+ await frame.locator("body").evaluate((body) => {
+ const w = body.ownerDocument.defaultView as any
+ w.Draw.loadPlugin((ui: any) => {
+ const graph = ui.editor.graph
+ graph.setSelectionCells([
+ graph.model.getCell("2"),
+ graph.model.getCell("4"),
+ ])
+ })
+ })
+ const text = await toolText("get_selection")
+ expect(text).toContain('2 cells selected on page "Page-1"')
+ expect(text).toContain('id="2" shape "Pick me" at x=40 y=40 w=120 h=60')
+ expect(text).toContain('id="4" edge "link" from "2" to "3"')
+ expect(text).not.toContain('id="3"')
+})
+
// --- Version cards from the server's History (shell/node-versions-source.ts) ---
test("the version cards follow the server's History; undo, redo and restore go through it", async ({
diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts
index 8d1e0cb3..1ec04470 100644
--- a/packages/mcp-server/tests/http-server.test.ts
+++ b/packages/mcp-server/tests/http-server.test.ts
@@ -29,6 +29,7 @@ import {
onStateChange,
previewUrl,
requestExport,
+ requestSelection,
requestSync,
restoreHistoryEntry,
setDrawioDir,
@@ -37,6 +38,7 @@ import {
shellConfig,
shutdown,
startHttpServer,
+ waitForSelection,
waitForSync,
} from "../src/http-server.ts"
@@ -424,6 +426,69 @@ describe("export requests", () => {
})
})
+describe("selection requests (get_selection)", () => {
+ const picked = {
+ pageId: "p1",
+ pageName: "Page-1",
+ cells: [{ id: "3", label: "Login", edge: false }],
+ }
+
+ it("shows the request to the page and takes its answer once", async () => {
+ const id = "mcp-selection"
+ setState(id, "x")
+ expect(requestSelection(id)).toBe(true)
+ const poll = JSON.parse(
+ (await request(`/api/state?sessionId=${id}`)).body,
+ )
+ expect(poll.selectionId).toMatch(/^[0-9a-f-]{36}$/)
+ // The request survives a write meanwhile
+ setState(id, "y")
+ const waiting = waitForSelection(id, 3000)
+ const answer = await postJson("/api/state", {
+ sessionId: id,
+ selectionId: poll.selectionId,
+ selection: picked,
+ })
+ expect(answer.status).toBe(200)
+ expect(await waiting).toEqual(picked)
+ // Taken: the next poll shows no request
+ const after = JSON.parse(
+ (await request(`/api/state?sessionId=${id}`)).body,
+ )
+ expect(after.selectionId).toBeNull()
+ expect(getState(id)?.xml).toBe("y")
+ })
+
+ it("ignores an answer to another request, and gives up in time", async () => {
+ const id = "mcp-selection-late"
+ setState(id, "x")
+ requestSelection(id)
+ const first = JSON.parse(
+ (await request(`/api/state?sessionId=${id}`)).body,
+ )
+ requestSelection(id)
+ await postJson("/api/state", {
+ sessionId: id,
+ selectionId: first.selectionId,
+ selection: picked,
+ })
+ expect(getState(id)?.selection).toBeUndefined()
+ expect(getState(id)?.selectionId).not.toBe(first.selectionId)
+ // A malformed answer is not taken either
+ const second = getState(id)?.selectionId
+ await postJson("/api/state", {
+ sessionId: id,
+ selectionId: second,
+ selection: { cells: "3" },
+ })
+ expect(getState(id)?.selection).toBeUndefined()
+ expect(await waitForSelection(id, 250)).toBeNull()
+ // The request is withdrawn with the timeout
+ expect(getState(id)?.selectionId).toBeUndefined()
+ expect(requestSelection("mcp-unknown-session")).toBe(false)
+ })
+})
+
describe("a session state recreated after it was lost", () => {
const SAVED = ``
const getJson = async (id: string) =>
diff --git a/packages/mcp-server/tests/selection.test.ts b/packages/mcp-server/tests/selection.test.ts
new file mode 100644
index 00000000..d24fc664
--- /dev/null
+++ b/packages/mcp-server/tests/selection.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest"
+import {
+ describeSelection,
+ parseSelectionAnswer,
+ type SelectionAnswer,
+} from "../src/selection.ts"
+
+const page = { pageId: "p1", pageName: "Flow" }
+
+describe("get_selection text", () => {
+ it("lists shapes with their geometry and edges with their ends", () => {
+ const text = describeSelection(
+ {
+ ...page,
+ cells: [
+ {
+ id: "3",
+ label: "Login",
+ edge: false,
+ geometry: { x: 100, y: 100, width: 120, height: 60 },
+ },
+ {
+ id: "7",
+ label: "",
+ edge: false,
+ geometry: { x: 20, y: 30, width: 80, height: 40 },
+ parent: "box",
+ },
+ {
+ id: "5",
+ label: "yes",
+ edge: true,
+ source: "3",
+ target: "4",
+ },
+ { id: "9", label: "", edge: true },
+ ],
+ },
+ null,
+ )
+ expect(text).toContain(
+ '4 cells selected on page "Flow" (page_id="p1"):',
+ )
+ expect(text).toContain(
+ '- id="3" shape "Login" at x=100 y=100 w=120 h=60',
+ )
+ expect(text).toContain(
+ '- id="7" shape (no label) at x=20 y=30 w=80 h=40 inside "box"',
+ )
+ expect(text).toContain('- id="5" edge "yes" from "3" to "4"')
+ expect(text).toContain('- id="9" edge (no label) (not connected)')
+ expect(text).toContain('edit_diagram (page_id="p1")')
+ })
+
+ it("says clearly when nothing is selected", () => {
+ const text = describeSelection({ ...page, cells: [] }, null)
+ expect(text).toBe(
+ 'Nothing is selected on page "Flow" (page_id="p1"). Ask the user to select the shapes they mean in the preview, or call get_diagram.',
+ )
+ expect(
+ describeSelection(
+ { pageId: null, pageName: null, cells: [] },
+ null,
+ ),
+ ).toContain("Nothing is selected on the page on screen.")
+ })
+
+ it("names the external draw.io when the page cannot read the editor", () => {
+ const unavailable: SelectionAnswer = {
+ pageId: null,
+ pageName: null,
+ cells: [],
+ unavailable: true,
+ }
+ expect(
+ describeSelection(unavailable, "https://embed.diagrams.net"),
+ ).toBe(
+ "The preview loads draw.io from https://embed.diagrams.net (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.",
+ )
+ // Same origin, but the bridge did not attach (a draw.io change)
+ expect(describeSelection(unavailable, null)).toContain(
+ "could not read the editor's selection",
+ )
+ })
+})
+
+describe("the page's selection answer", () => {
+ it("takes the fields it knows and drops malformed cells", () => {
+ expect(
+ parseSelectionAnswer({
+ pageId: "p1",
+ pageName: "Flow",
+ cells: [
+ {
+ id: "3",
+ label: "Login",
+ edge: false,
+ geometry: { x: 1, y: 2, width: 3, height: 4 },
+ parent: "box",
+ style: "ignored",
+ },
+ { id: "5", edge: true, source: "3", target: 4 },
+ { id: "", label: "no id" },
+ "not a cell",
+ { id: "8", geometry: { x: "1", y: 2 } },
+ ],
+ }),
+ ).toEqual({
+ pageId: "p1",
+ pageName: "Flow",
+ cells: [
+ {
+ id: "3",
+ label: "Login",
+ edge: false,
+ geometry: { x: 1, y: 2, width: 3, height: 4 },
+ parent: "box",
+ },
+ { id: "5", label: "", edge: true, source: "3" },
+ { id: "8", label: "", edge: false },
+ ],
+ })
+ })
+
+ it("keeps the unavailable mark and refuses other shapes", () => {
+ expect(parseSelectionAnswer({ unavailable: true })).toEqual({
+ pageId: null,
+ pageName: null,
+ cells: [],
+ unavailable: true,
+ })
+ expect(parseSelectionAnswer({ cells: "3,4" })).toBeNull()
+ expect(parseSelectionAnswer("3")).toBeNull()
+ expect(parseSelectionAnswer(null)).toBeNull()
+ })
+})
diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts
index 18c139fe..c8dbeae1 100644
--- a/packages/mcp-server/tests/server-wiring.test.ts
+++ b/packages/mcp-server/tests/server-wiring.test.ts
@@ -9,8 +9,9 @@
* package before testing). Running from source via tsx means it executes as
* part of the normal `vitest run`.
*
- * We deliberately do NOT call start_session — it would open a real browser
- * window via open(). The browser bridge is covered by the Playwright e2e suite.
+ * start_session is called only at the end, with BROWSER=none so no browser
+ * window opens, for the tools that need a session but no preview tab. The
+ * browser bridge is covered by the Playwright e2e suite.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
@@ -44,6 +45,7 @@ const EXPECTED_TOOLS = [
"get_drawing_guide",
"get_shape_library",
"screenshot_diagram",
+ "get_selection",
"list_saved_diagrams",
"restore_version",
]
@@ -89,7 +91,15 @@ beforeAll(async () => {
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
- env: { ...process.env, DRAWIO_DATA_DIR: dataDir },
+ env: {
+ ...process.env,
+ DRAWIO_DATA_DIR: dataDir,
+ // For the session tests at the end: no browser window, the
+ // canvas shell as the preview, a port away from other servers
+ BROWSER: "none",
+ DRAWIO_PREVIEW_UI: "shell",
+ PORT: String(6500 + Math.floor(Math.random() * 400)),
+ },
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
@@ -215,6 +225,15 @@ describe("MCP server wiring", () => {
expect(resp.result.content[0].text).toContain("start_session")
})
+ it("refuses get_selection without a session", async () => {
+ const resp = await send("tools/call", {
+ name: "get_selection",
+ arguments: {},
+ })
+ expect(resp.result.isError).toBe(true)
+ expect(resp.result.content[0].text).toContain("start_session")
+ })
+
it("keeps every description and the instructions within the host limit", async () => {
const resp = await send("tools/list", {})
for (const tool of resp.result.tools) {
@@ -336,3 +355,23 @@ describe("load_diagram dual-source arguments", () => {
expect(resp.result?.content?.[0]?.text).toContain("No active session")
})
})
+
+// After the tests above: start_session makes a session current
+describe("MCP server wiring with a session, without a preview tab", () => {
+ it("get_selection says the preview tab is not connected", async () => {
+ const started = await send("tools/call", {
+ name: "start_session",
+ arguments: {},
+ })
+ expect(started.result.isError).toBeFalsy()
+ expect(started.result.content[0].text).toContain("Browser URL:")
+ const resp = await send("tools/call", {
+ name: "get_selection",
+ arguments: {},
+ })
+ expect(resp.result.isError).toBe(true)
+ expect(resp.result.content[0].text).toBe(
+ "Error: The preview tab is not open, or has not connected yet.",
+ )
+ })
+})
diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts
index 7959d580..7f713b08 100644
--- a/tests/unit/mcp-sync-core.test.ts
+++ b/tests/unit/mcp-sync-core.test.ts
@@ -8,6 +8,7 @@ import {
type SyncStatus,
tokenFromPage,
} from "@/packages/mcp-server/shell/mcp-sync-core"
+import type { SelectionAnswer } from "@/packages/mcp-server/src/selection"
// The shell's side of the MCP server protocol, driven step by step with a
// stubbed server (fetch) and canvas, as tests/unit/mcp-preview-recovery.test.ts
@@ -59,7 +60,15 @@ function open(options: { currentPageId?: string | null } = {}) {
const notices: SyncNotice[] = []
const statuses: SyncStatus[] = []
let recreated = 0
- const canvas = { xml: "", pageId: options.currentPageId ?? null }
+ const canvas: {
+ xml: string
+ pageId: string | null
+ selection: SelectionAnswer
+ } = {
+ xml: "",
+ pageId: options.currentPageId ?? null,
+ selection: { pageId: "p1", pageName: "Page-1", cells: [] },
+ }
const sync = createMcpSync({
sessionId: "mcp-test",
apiBase: "/api",
@@ -86,6 +95,7 @@ function open(options: { currentPageId?: string | null } = {}) {
}),
currentXml: () => canvas.xml,
currentPageId: () => canvas.pageId,
+ readSelection: () => canvas.selection,
},
onNotice: (notice) => notices.push(notice),
onStatus: (status) => statuses.push(status),
@@ -863,6 +873,130 @@ describe("MCP sync export requests", () => {
})
})
+describe("MCP sync selection requests (get_selection)", () => {
+ const selectionState = (extra: Record = {}) =>
+ state("S1", 2, "A", { selectionId: "sel-1", ...extra })
+ const picked: SelectionAnswer = {
+ pageId: "p1",
+ pageName: "Page-1",
+ cells: [
+ {
+ id: "3",
+ label: "Login",
+ edge: false,
+ geometry: { x: 100, y: 100, width: 120, height: 60 },
+ },
+ { id: "5", label: "", edge: true, source: "3", target: "4" },
+ ],
+ }
+
+ it("answers a request once, with what the editor has selected", async () => {
+ const t = await inStep()
+ t.canvas.selection = picked
+ const poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ const answer = t.next("POST")
+ expect(answer.body).toEqual({
+ sessionId: "mcp-test",
+ selectionId: "sel-1",
+ selection: picked,
+ })
+ answer.answer({ status: 200, body: { success: true } })
+ // The server still shows the request on the next poll: not again
+ const again = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await again
+ expect(t.posts()).toHaveLength(0)
+ // A new request is answered
+ t.canvas.selection = { pageId: "p1", pageName: "Page-1", cells: [] }
+ const later = t.sync.poll()
+ t.next("GET").answer(selectionState({ selectionId: "sel-2" }))
+ await later
+ expect(t.next("POST").body).toMatchObject({
+ selectionId: "sel-2",
+ selection: { cells: [] },
+ })
+ })
+
+ it("says when the page cannot reach the editor", async () => {
+ const t = await inStep()
+ t.canvas.selection = {
+ pageId: null,
+ pageName: null,
+ cells: [],
+ unavailable: true,
+ }
+ const poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ expect(t.next("POST").body.selection).toEqual({
+ pageId: null,
+ pageName: null,
+ cells: [],
+ unavailable: true,
+ })
+ })
+
+ it("waits until draw.io is up, where the editor is reached", async () => {
+ const t = open()
+ t.canvas.selection = picked
+ let poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ expect(t.posts()).toHaveLength(0)
+ t.sync.setReady(true)
+ // The thumbnail of the write loaded before draw.io was ready
+ t.nextExport().answer(null)
+ await t.settle()
+ poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ expect(t.next("POST").body).toMatchObject({
+ selectionId: "sel-1",
+ selection: picked,
+ })
+ })
+
+ it("never answers while a projection is on screen", async () => {
+ const t = await inStep()
+ t.canvas.selection = picked
+ // A projection shows another page: its selection is not the user's
+ let poll = t.sync.poll()
+ t.next("GET").answer(
+ selectionState({
+ exportFormat: "svg",
+ exportId: "e1",
+ exportXml: "page 2 only",
+ }),
+ )
+ await poll
+ expect(t.sync.read().projectionActive).toBe(true)
+ expect(t.posts()).toHaveLength(0)
+ // The export answers and the document comes back
+ await vi.advanceTimersByTimeAsync(600)
+ t.nextExport().answer({ data: "" })
+ await t.settle()
+ t.next("POST").answer({ status: 200, body: { success: true } })
+ await t.settle()
+ await t.settle()
+ t.next("GET").answer(selectionState())
+ await t.settle()
+ await t.settle()
+ expect(t.posts()).toHaveLength(0)
+ t.sync.onDrawioLoad()
+ expect(t.sync.read().projectionActive).toBe(false)
+ // The request is still pending: answered now
+ poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ expect(t.next("POST").body).toMatchObject({
+ selectionId: "sel-1",
+ selection: picked,
+ })
+ })
+})
+
describe("MCP sync History", () => {
const historyList = {
status: 200,