From b3dcd91f547c403d50070037c3121dd9317efc15 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 15:59:01 +0900 Subject: [PATCH] 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). --- lib/drawio/editor-bridge.ts | 49 +++++++++ packages/mcp-server/shell/mcp-sync-core.ts | 29 +++++- packages/mcp-server/shell/use-mcp-sync.ts | 14 ++- packages/mcp-server/src/drawing-guide.ts | 1 + packages/mcp-server/src/http-server.ts | 69 +++++++++++++ packages/mcp-server/src/index.ts | 99 +++++++++++++++++- packages/mcp-server/src/selection.ts | 112 +++++++++++++++++++++ 7 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 packages/mcp-server/src/selection.ts diff --git a/lib/drawio/editor-bridge.ts b/lib/drawio/editor-bridge.ts index 53fe5073..157b5a86 100644 --- a/lib/drawio/editor-bridge.ts +++ b/lib/drawio/editor-bridge.ts @@ -11,6 +11,7 @@ */ import { isSameDocument, sameFileVars } from "@/lib/diagram-diff" import { hasCells } from "@/packages/mcp-server/src/pages.ts" +import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts" import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store" type EditorUi = any @@ -599,6 +600,54 @@ function readSelection(): SelectedCell[] { })) } +/** + * The selection with what an editing model needs (the MCP's get_selection): + * the page it is on, each cell's label, an edge's ends, a shape's geometry + * as the XML has it (relative to its container) and that container. Null + * without an editor (a cross-origin draw.io). + */ +export function readSelectionDetails(): SelectionAnswer | null { + const g = graph() + if (!g) return null + const page = ui?.currentPage + const defaultParent = g.getDefaultParent?.() + const cells = (g.getSelectionCells() as any[]) + .filter((cell) => cell?.id) + .map((cell) => { + const info: SelectionAnswer["cells"][number] = { + id: String(cell.id), + label: cellLabel(cell), + edge: !!g.model.isEdge(cell), + } + if (info.edge) { + const source = g.model.getTerminal(cell, true) + const target = g.model.getTerminal(cell, false) + if (source?.id) info.source = String(source.id) + if (target?.id) info.target = String(target.id) + } else { + const geo = g.getCellGeometry(cell) + if (geo) { + info.geometry = { + x: geo.x, + y: geo.y, + width: geo.width, + height: geo.height, + } + } + } + const parent = g.model.getParent(cell) + if (parent?.id && parent !== defaultParent) { + info.parent = String(parent.id) + } + return info + }) + return { + pageId: page?.getId ? String(page.getId()) : null, + pageName: page?.getName ? String(page.getName()) : null, + cells, + } +} + function readSelectionRect() { const g = graph() if (!g || g.isSelectionEmpty()) return null diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts index 79e14e2f..73c2f8fe 100644 --- a/packages/mcp-server/shell/mcp-sync-core.ts +++ b/packages/mcp-server/shell/mcp-sync-core.ts @@ -21,6 +21,7 @@ import { parseMxfile, serializeMxfile, } from "@/packages/mcp-server/src/pages.ts" +import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts" export type ExportFormat = "png" | "svg" | "xmlsvg" @@ -35,6 +36,8 @@ export interface ServerState { exportXml: string | null exportOptions: { width?: number; pageId?: string } | null exportId: string | null + /** A get_selection request waiting for this tab's answer */ + selectionId?: string | null } /** An entry of GET /api/history */ @@ -93,6 +96,9 @@ export interface SyncCanvas { currentXml(): string /** The page on screen; null when unknown (external draw.io) */ currentPageId(): string | null + /** The cells selected in the editor, for get_selection; `unavailable` + * when the page cannot reach the editor (external draw.io) */ + readSelection(): SelectionAnswer } export type SyncStatus = "waiting" | "connected" | "offline" @@ -305,6 +311,9 @@ export function createMcpSync(options: SyncOptions): McpSync { let forceReload = false let pendingSyncExport = false let syncExportSeq = 0 + // The selection request answered last: the server shows a request until + // the answer arrives, and polls overlap + let answeredSelectionId: string | null = null let status: SyncStatus = "waiting" let interval: ReturnType | null = null @@ -722,9 +731,23 @@ export function createMcpSync(options: SyncOptions): McpSync { ) { startMcpExport(s, justLoaded) } - // Extension point (plan step 6, the get_selection tool): a - // selection request in the state would be answered here, like - // the sync request above, with the ids the editor bridge reads + // A selection request (get_selection): the editor's selection + // on the page the user is viewing, once draw.io is up (the + // bridge attaches to it at its first load), and not while a + // projection replaces that page + if ( + s.selectionId && + s.selectionId !== answeredSelectionId && + isReady && + !projectionExportActive + ) { + answeredSelectionId = s.selectionId + postJson("/state", { + sessionId, + selectionId: s.selectionId, + selection: canvas.readSelection(), + }).catch(() => {}) + } } catch { setStatus("offline") } diff --git a/packages/mcp-server/shell/use-mcp-sync.ts b/packages/mcp-server/shell/use-mcp-sync.ts index 2c7e49c0..fc4c681e 100644 --- a/packages/mcp-server/shell/use-mcp-sync.ts +++ b/packages/mcp-server/shell/use-mcp-sync.ts @@ -3,7 +3,10 @@ import { toast } from "sonner" import { useDiagram } from "@/contexts/diagram-context" import { useDictionary } from "@/hooks/use-dictionary" import { diffDiagrams } from "@/lib/diagram-diff" -import { highlightChangedCells } from "@/lib/drawio/editor-bridge" +import { + highlightChangedCells, + readSelectionDetails, +} from "@/lib/drawio/editor-bridge" import { useCanvasStore } from "@/stores/canvas-store" import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core" import type { ShellConfig } from "./runtime-config" @@ -53,6 +56,15 @@ export function useMcpSync(config: ShellConfig): { diagramRef.current.requestExport(request, timeoutMs), currentXml: () => diagramRef.current.chartXMLRef.current, currentPageId: () => useCanvasStore.getState().currentPageId, + // The bridge reads the editor directly; without it (an + // external draw.io) the selection cannot be read + readSelection: () => + readSelectionDetails() ?? { + pageId: null, + pageName: null, + cells: [], + unavailable: true, + }, }, onNotice: (notice) => toast(dictRef.current.shell[notice], { duration: 8000 }), diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts index 25d4c1f3..ac721e47 100644 --- a/packages/mcp-server/src/drawing-guide.ts +++ b/packages/mcp-server/src/drawing-guide.ts @@ -19,6 +19,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide ## Workflow - create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file. +- When the user refers to what they selected in the preview ("this box", "these arrows"), call get_selection: it returns the selected cells' ids, labels and page, ready for edit_diagram. - Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes. - Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message. - Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4). diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index da46a32b..4464c8cf 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -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 { + 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 { 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 diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 525fd526..d5ffafdc 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -57,14 +57,17 @@ import { keepInHistory, onSessionRecreate, onStateChange, + PREVIEW_UI, previewUrl, requestExport, + requestSelection, requestSync, restoreHistoryEntry, restoreSavedSession, setState, shutdown, startHttpServer, + waitForSelection, waitForSync, } from "./http-server.ts" import { parseDrawioFileContent } from "./load-diagram.ts" @@ -92,6 +95,7 @@ import { wrapCellsInModel, } from "./pages.ts" import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts" +import { describeSelection } from "./selection.ts" import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts" import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts" import { XML_REFERENCE } from "./xml-reference.ts" @@ -179,6 +183,7 @@ Tools: - create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full for several pages. - edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts. - get_diagram: read the current XML, including the user's manual edits. +- get_selection: the cells the user has selected in the preview, when they say "this box" or "these arrows". - screenshot_diagram: see the rendered diagram as an image. - load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths. - list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=. @@ -629,7 +634,9 @@ To clear the canvas to one blank page, send only the two root cells { + try { + if (!currentSession) { + return { + content: [ + { + type: "text", + text: "Error: No active session. Please call start_session first.", + }, + ], + isError: true, + } + } + const sessionId = currentSession.id + // The classic preview page never answers: it has no access to + // the editor (the shell reads it through the same-origin frame) + if (PREVIEW_UI !== "shell") { + return { + content: [ + { + type: "text", + text: "The classic preview page cannot read the selection; start the server with DRAWIO_PREVIEW_UI=shell. Ask the user which shapes they mean, or call get_diagram.", + }, + ], + } + } + if (previewStalled(sessionId)) { + return previewStalledError(sessionId) + } + if (sessionState(sessionId)?.lastPolled === undefined) { + return { + content: [ + { + type: "text", + text: "Error: The preview tab is not open, or has not connected yet.", + }, + ], + isError: true, + } + } + requestSelection(sessionId) + const answer = await waitForSelection(sessionId) + if (!answer) { + return { + content: [ + { + type: "text", + text: "Error: Reading the selection timed out. Make sure the preview tab is open and in front.", + }, + ], + isError: true, + } + } + return { + content: [ + { + type: "text", + text: describeSelection( + answer, + isSameOriginDrawio() ? null : DRAWIO_BASE_URL, + ), + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("get_selection failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + // The browser bridge has one export slot per session, so export requests // run one at a time: a concurrent call waits for the previous one. let exportQueue: Promise = Promise.resolve() @@ -1421,7 +1516,7 @@ server.registerTool( description: "Render the diagram in the preview and return it as a PNG image, so you can see your own result. " + "Call this once after drawing or heavily editing a complex diagram, then fix overlaps and edges that cross shapes. " + - "Without a page selector it shows the page on screen. Needs the preview tab to be open.", + "Without a page selector it shows the page on screen; with one it renders that page without changing what the user sees. Needs the preview tab to be open.", inputSchema: { ...pageSelectorSchema }, annotations: { readOnlyHint: true, openWorldHint: false }, }, diff --git a/packages/mcp-server/src/selection.ts b/packages/mcp-server/src/selection.ts new file mode 100644 index 00000000..c3ab3d77 --- /dev/null +++ b/packages/mcp-server/src/selection.ts @@ -0,0 +1,112 @@ +/** + * The cells the user has selected in the preview, as the page reads them + * from the editor (shell/use-mcp-sync.ts) and the get_selection tool + * reports them. The shape is shared with the shell; only plain data, so the + * server can take it from a POST as it is. + */ + +export interface SelectedCellInfo { + id: string + /** The label as text, HTML removed and long ones cut */ + label: string + edge: boolean + source?: string + target?: string + /** The cell's geometry as the XML has it (relative to its parent) */ + geometry?: { x: number; y: number; width: number; height: number } + /** The container the cell is in, when it is not on the layer itself */ + parent?: string +} + +export interface SelectionAnswer { + /** The page on screen */ + pageId: string | null + pageName: string | null + cells: SelectedCellInfo[] + /** The page cannot reach the editor (an external draw.io) */ + unavailable?: boolean +} + +/** A POST body's selection, if it has the shape above; else null */ +export function parseSelectionAnswer(value: unknown): SelectionAnswer | null { + if (!value || typeof value !== "object") return null + const given = value as Record + if (given.unavailable === true) { + return { pageId: null, pageName: null, cells: [], unavailable: true } + } + if (!Array.isArray(given.cells)) return null + const text = (v: unknown) => (typeof v === "string" ? v : null) + const cells: SelectedCellInfo[] = [] + for (const raw of given.cells) { + if (!raw || typeof raw !== "object") continue + const c = raw as Record + if (typeof c.id !== "string" || !c.id) continue + const cell: SelectedCellInfo = { + id: c.id, + label: text(c.label) ?? "", + edge: c.edge === true, + } + if (text(c.source)) cell.source = c.source as string + if (text(c.target)) cell.target = c.target as string + if (text(c.parent)) cell.parent = c.parent as string + const g = c.geometry as Record | undefined + if ( + g && + typeof g === "object" && + [g.x, g.y, g.width, g.height].every( + (n) => typeof n === "number" && Number.isFinite(n), + ) + ) { + cell.geometry = { + x: g.x as number, + y: g.y as number, + width: g.width as number, + height: g.height as number, + } + } + cells.push(cell) + } + return { pageId: text(given.pageId), pageName: text(given.pageName), cells } +} + +/** + * The get_selection result text. `externalDrawio` names the draw.io origin + * when the preview loads it from another origin, where the page cannot + * read the editor. + */ +export function describeSelection( + answer: SelectionAnswer, + externalDrawio: string | null, +): string { + if (answer.unavailable) { + return externalDrawio + ? `The preview loads draw.io from ${externalDrawio} (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.` + : "The preview could not read the editor's selection. Ask the user which shapes they mean, or call get_diagram." + } + const page = answer.pageName + ? `page "${answer.pageName}"${answer.pageId ? ` (page_id="${answer.pageId}")` : ""}` + : "the page on screen" + if (answer.cells.length === 0) { + return `Nothing is selected on ${page}. Ask the user to select the shapes they mean in the preview, or call get_diagram.` + } + const lines = answer.cells.map((cell) => { + const label = cell.label ? `"${cell.label}"` : "(no label)" + if (cell.edge) { + const ends = + cell.source || cell.target + ? ` from ${cell.source ? `"${cell.source}"` : "nothing"} to ${cell.target ? `"${cell.target}"` : "nothing"}` + : " (not connected)" + return `- id="${cell.id}" edge ${label}${ends}` + } + const g = cell.geometry + const where = g + ? ` at x=${g.x} y=${g.y} w=${g.width} h=${g.height}` + : "" + const parent = cell.parent ? ` inside "${cell.parent}"` : "" + return `- id="${cell.id}" shape ${label}${where}${parent}` + }) + const count = + answer.cells.length === 1 ? "1 cell" : `${answer.cells.length} cells` + const target = answer.pageId ? ` (page_id="${answer.pageId}")` : "" + return `${count} selected on ${page}:\n${lines.join("\n")}\n\nUse these ids with edit_diagram${target}; call get_diagram for their full XML.` +}