diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts
index 06ed689d..7d6955a6 100644
--- a/packages/mcp-server/shell/mcp-sync-core.ts
+++ b/packages/mcp-server/shell/mcp-sync-core.ts
@@ -769,7 +769,21 @@ export function createMcpSync(options: SyncOptions): McpSync {
) {
const id = s.selectionId
answeredSelectionId = id
- const answer = () =>
+ const answer = () => {
+ // A hidden tab answers later: the canvas may have moved
+ // on since the poll (a projection, a full load, draw.io
+ // starting over). The same conditions as above; when
+ // they no longer hold, the next poll decides again
+ if (
+ !isReady ||
+ projectionExportActive ||
+ canvas.loadPending()
+ ) {
+ if (answeredSelectionId === id) {
+ answeredSelectionId = null
+ }
+ return
+ }
postJson("/state", {
sessionId,
selectionId: id,
@@ -784,6 +798,7 @@ export function createMcpSync(options: SyncOptions): McpSync {
answeredSelectionId = null
}
})
+ }
// A hidden tab lets a tab in front (the same session open
// twice) answer first; the server takes the first answer
// and ignores the later one
diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts
index 64261805..77444501 100644
--- a/tests/unit/mcp-sync-core.test.ts
+++ b/tests/unit/mcp-sync-core.test.ts
@@ -1078,6 +1078,84 @@ describe("MCP sync selection requests (get_selection)", () => {
})
})
+ it("holds a hidden tab's answer when a projection started meanwhile, and answers once it ended", async () => {
+ const t = await inStep({ isHidden: () => true })
+ t.canvas.selection = picked
+ let poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ // The next poll, 2 s later, brings an export of another page: the
+ // projection replaces the user's page before the answer is due
+ await vi.advanceTimersByTimeAsync(2000)
+ 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)
+ t.canvas.selection = { pageId: "p2", pageName: "Page-2", cells: [] }
+ await vi.advanceTimersByTimeAsync(500)
+ expect(t.posts()).toHaveLength(0)
+ // The export answers and the document comes back
+ await vi.advanceTimersByTimeAsync(100)
+ 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()
+ t.sync.onDrawioLoad()
+ t.canvas.selection = picked
+ // The request is still pending: this poll schedules it again
+ poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ expect(t.posts()).toHaveLength(0)
+ await vi.advanceTimersByTimeAsync(2500)
+ expect(t.next("POST").body).toMatchObject({
+ selectionId: "sel-1",
+ selection: picked,
+ })
+ })
+
+ it("holds a hidden tab's answer while a full load has yet to reach the editor", async () => {
+ const t = await inStep({ isHidden: () => true })
+ t.canvas.selection = picked
+ let poll = t.sync.poll()
+ t.next("GET").answer(selectionState())
+ await poll
+ // A new version arrives before the answer is due: loaded in full,
+ // the editor still shows the old document
+ await vi.advanceTimersByTimeAsync(2000)
+ t.canvas.loadPending = true
+ poll = t.sync.poll()
+ t.next("GET").answer(
+ state("S1", 3, "B", { selectionId: "sel-1" }),
+ )
+ await poll
+ expect(t.loads.at(-1)?.xml).toBe("B")
+ await vi.advanceTimersByTimeAsync(500)
+ expect(t.posts()).toHaveLength(0)
+ // draw.io reports the load: the next poll answers
+ t.canvas.loadPending = false
+ poll = t.sync.poll()
+ t.next("GET").answer(
+ state("S1", 3, "B", { selectionId: "sel-1" }),
+ )
+ await poll
+ await vi.advanceTimersByTimeAsync(2500)
+ 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