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
+49
View File
@@ -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
+26 -3
View File
@@ -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<typeof setInterval> | 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")
}
+13 -1
View File
@@ -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 }),
+1
View File
@@ -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).
+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
+97 -2
View File
@@ -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 <mxfile> 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=<id>.
@@ -629,7 +634,9 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`,
},
]
// The page on screen is the new document's first page
// The page on screen: the new document's first page after a
// full load, or the page the user was viewing when the write
// went on the canvas in place (both are the model's own work)
if (screenshot ?? config.autoScreenshot) {
const shot = await captureScreenshot(
currentSession.id,
@@ -1228,6 +1235,94 @@ server.registerTool(
},
)
// Tool: get_selection
server.registerTool(
"get_selection",
{
title: "Get selection",
description:
"Return the cells the user has selected in the preview: their ids and labels, for edges the source and target ids, for shapes the position and size, and the page they are on. " +
'Call this when the user refers to what they selected ("this box", "these arrows", "the selected shapes"), then act on the ids with edit_diagram. ' +
"Says when nothing is selected. Needs the preview tab to be open.",
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async () => {
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<unknown> = 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 },
},
+112
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown> | 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.`
}