fix(mcp-server): review fixes for get_selection

Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
  session as the export slot) instead of replacing each other's request,
  which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
  compared equal)
- the tool sees the page start_session actually opens: with dist/shell
  missing the classic page is in use, which never answers, so the tool
  says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
  diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
  the shell reports exists in the server's document

Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
  the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
  tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
  with the default parent, which is the group the user entered
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 734a31d4eb
commit 467fd25fd1
12 changed files with 316 additions and 31 deletions
+81 -2
View File
@@ -31,7 +31,9 @@ interface PendingExport {
const realSetTimeout = globalThis.setTimeout
function open(options: { currentPageId?: string | null } = {}) {
function open(
options: { currentPageId?: string | null; isHidden?: () => boolean } = {},
) {
const calls: Call[] = []
const fetchStub = vi.fn(
(url: string, init?: RequestInit) =>
@@ -63,10 +65,12 @@ function open(options: { currentPageId?: string | null } = {}) {
const canvas: {
xml: string
pageId: string | null
loadPending: boolean
selection: SelectionAnswer
} = {
xml: "",
pageId: options.currentPageId ?? null,
loadPending: false,
selection: { pageId: "p1", pageName: "Page-1", cells: [] },
}
const sync = createMcpSync({
@@ -95,11 +99,13 @@ function open(options: { currentPageId?: string | null } = {}) {
}),
currentXml: () => canvas.xml,
currentPageId: () => canvas.pageId,
loadPending: () => canvas.loadPending,
readSelection: () => canvas.selection,
},
onNotice: (notice) => notices.push(notice),
onStatus: (status) => statuses.push(status),
onStateRecreated: () => recreated++,
isHidden: options.isHidden,
})
const settle = () => new Promise((r) => realSetTimeout(r, 0))
const next = (method: string, prefix = "/api/") => {
@@ -155,7 +161,9 @@ const state = (
})
/** A tab in step with state S1 at version 2, showing diagram A, draw.io ready */
async function inStep(options: { currentPageId?: string | null } = {}) {
async function inStep(
options: { currentPageId?: string | null; isHidden?: () => boolean } = {},
) {
const t = open(options)
t.sync.setReady(true)
const poll = t.sync.poll()
@@ -958,6 +966,77 @@ describe("MCP sync selection requests (get_selection)", () => {
})
})
it("answers again at the next poll when the answer was not delivered", async () => {
const t = await inStep()
t.canvas.selection = picked
let poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
t.next("POST").fail()
await t.settle()
// The server still shows the request: answered again
poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
const retry = t.next("POST")
expect(retry.body.selectionId).toBe("sel-1")
retry.answer({ status: 500, body: {} })
await t.settle()
poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
t.next("POST").answer({ status: 200, body: { success: true } })
await t.settle()
// Delivered: not again
poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
expect(t.posts()).toHaveLength(0)
})
it("waits for a full load to reach the editor before reading the selection", async () => {
// The poll that brings the request may also bring a new document,
// loaded in full: until draw.io reports it, the editor shows the old
// one, whose cells and pages the answer would name
const t = await inStep()
t.canvas.selection = picked
t.canvas.loadPending = true
let poll = t.sync.poll()
t.next("GET").answer(
state("S1", 3, "<mxfile>B</mxfile>", { selectionId: "sel-1" }),
)
await poll
expect(t.loads.at(-1)?.xml).toBe("<mxfile>B</mxfile>")
expect(t.posts()).toHaveLength(0)
t.canvas.loadPending = false
poll = t.sync.poll()
t.next("GET").answer(
state("S1", 3, "<mxfile>B</mxfile>", { selectionId: "sel-1" }),
)
await poll
expect(t.next("POST").body).toMatchObject({
selectionId: "sel-1",
selection: picked,
})
})
it("lets a tab in front answer first when this one is hidden", async () => {
// The same session open twice: the hidden tab's selection is not
// the user's; it answers a poll later, in case it is the only tab
const t = await inStep({ isHidden: () => true })
t.canvas.selection = picked
const 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).toEqual({
sessionId: "mcp-test",
selectionId: "sel-1",
selection: picked,
})
})
it("never answers while a projection is on screen", async () => {
const t = await inStep()
t.canvas.selection = picked