feat(mcp-server): get_selection reads the cells the user selected in the shell

The server asks the preview tab for the selection the way it asks for an export (a random request id in GET /api/state, the answer in a POST with that id, 10 s to answer). The shell answers through the editor bridge with each cell's id, label, an edge's ends, a shape's geometry and the page on screen; without a same-origin editor it says so, and the tool names the external draw.io. The classic page cannot answer, so the tool says that at once. screenshot_diagram's description now says a page selector renders that page without changing the page on screen (PNG exports by pageId already did).
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 5c923adf7b
commit b3dcd91f54
7 changed files with 367 additions and 6 deletions
+69
View File
@@ -58,6 +58,7 @@ import {
} from "./history.ts"
import { log } from "./logger.ts"
import { BLANK_MXFILE } from "./pages.ts"
import { parseSelectionAnswer, type SelectionAnswer } from "./selection.ts"
// Configurable draw.io embed URL for private deployments. Set, it replaces
// the bundled copy (see drawioDir below).
@@ -245,6 +246,8 @@ interface SessionState {
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
exportId?: string // Random id of the pending export, echoed with its result
exportData?: string // Base64/SVG data returned by browser after export
selectionId?: string // Random id of the pending get_selection request
selection?: SelectionAnswer // The page's answer to it
}
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
@@ -319,6 +322,8 @@ export function setState(
exportOptions: existing?.exportOptions,
exportId: existing?.exportId,
exportData: existing?.exportData, // Preserve export result
selectionId: existing?.selectionId, // Preserve pending selection request
selection: existing?.selection,
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
if (notify) stateListener?.(sessionId, xml)
@@ -396,6 +401,61 @@ export async function waitForSync(
return false // Timeout
}
/**
* Ask the preview tab which cells the user has selected (the get_selection
* tool). Answered through the poll like an export: the tab sees selectionId
* in GET /api/state and POSTs its reading with that id. Returns false when
* the session is unknown.
*/
export function requestSelection(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.selection = undefined
// Random, as exportId: a late answer to an earlier request, or one meant
// for the process that had this port before, is not taken for this one
state.selectionId = randomUUID()
return true
}
/** The tab's answer to the pending selection request, or null in time */
export async function waitForSelection(
sessionId: string,
timeoutMs = 10000,
): Promise<SelectionAnswer | null> {
const start = Date.now()
let answer: SelectionAnswer | undefined
while (Date.now() - start < timeoutMs) {
// Re-read the store entry each tick: setState replaces it
answer = stateStore.get(sessionId)?.selection
if (answer) break
await new Promise((r) => setTimeout(r, 100))
}
const state = stateStore.get(sessionId)
if (state) {
state.selection = undefined
state.selectionId = undefined
}
if (!answer) log.warn(`Selection timeout for session=${sessionId}`)
return answer ?? null
}
/** POST /api/state with a selection: the tab's answer to requestSelection */
function handleSelectionResult(
sessionId: string,
data: { selectionId?: unknown; selection?: unknown },
): void {
const state = stateStore.get(sessionId)
if (!state || data.selectionId !== state.selectionId) {
log.debug(`Ignored a late selection answer for session=${sessionId}`)
return
}
const answer = parseSelectionAnswer(data.selection)
if (!answer) return
state.selection = answer
state.selectionId = undefined
log.debug(`Selection received for session=${sessionId}`)
}
export function startHttpServer(port = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
@@ -675,6 +735,7 @@ function handleStateApi(
exportXml: state?.exportXml || null,
exportOptions: state?.exportOptions || null,
exportId: state?.exportId ?? null,
selectionId: state?.selectionId ?? null,
}),
)
} else if (req.method === "POST") {
@@ -712,6 +773,14 @@ function handleStateApi(
return
}
// The tab is answering a selection request (get_selection)
if (data.selection !== undefined) {
handleSelectionResult(sessionId, data)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
return
}
// A push can come before the tab's first poll after a
// restart: recover the saved file first, so it is compared
// with that and never overwrites it unseen