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.` +}