diff --git a/mcp-server/index.js b/mcp-server/index.js index 0ab0ff4..33ff0b5 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -2,6 +2,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { FlowState } from "./src/state.js"; import { SatoriRenderer } from "./src/renderer/satori-renderer.js"; import { createServer } from "./src/server.js"; +import { createSelectionBridge } from "./src/selection-bridge.js"; async function main() { const state = new FlowState(); @@ -28,16 +29,20 @@ async function main() { process.stderr.write("Screen creation from HTML will not work.\n"); } - const server = createServer(state, renderer); + const bridge = createSelectionBridge(); + + const server = createServer(state, renderer, bridge); const transport = new StdioServerTransport(); // Graceful shutdown process.on("SIGINT", async () => { + bridge.close(); await server.close(); process.exit(0); }); process.on("SIGTERM", async () => { + bridge.close(); await server.close(); process.exit(0); }); diff --git a/mcp-server/src/selection-bridge.js b/mcp-server/src/selection-bridge.js new file mode 100644 index 0000000..d793e69 --- /dev/null +++ b/mcp-server/src/selection-bridge.js @@ -0,0 +1,97 @@ +import http from 'node:http'; + +const DEFAULT_PORT = 3337; + +/** + * Creates a small HTTP listener that receives selection snapshots from the + * running Drawd browser app and stores the latest one in memory so the + * get_current_selection MCP tool can read it. + * + * Endpoints: + * POST /selection — body: { items, filePath?, at? } → stored as lastSelection + * GET /selection — returns the current lastSelection (debug) + * + * The bridge binds to localhost only and never persists data. If the port is + * already in use we log a warning and return a stub; the MCP stdio server + * continues to run so offline tooling still works. + * + * @param {{ port?: number }} [options] + * @returns {{ get: () => (null | { payload: any, receivedAt: number }), close: () => void }} + */ +export function createSelectionBridge({ port } = {}) { + const listenPort = port ?? (Number(process.env.DRAWD_SELECTION_PORT) || DEFAULT_PORT); + let lastSelection = null; + + const server = http.createServer(async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url, `http://localhost:${listenPort}`); + + try { + if (req.method === 'POST' && url.pathname === '/selection') { + const body = await readBody(req); + lastSelection = { payload: body, receivedAt: Date.now() }; + return sendJson(res, 200, { ok: true }); + } + + if (req.method === 'GET' && url.pathname === '/selection') { + return sendJson(res, 200, lastSelection || { payload: null, receivedAt: null }); + } + + sendJson(res, 404, { error: 'Not found' }); + } catch (err) { + sendJson(res, 400, { error: err.message }); + } + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + process.stderr.write( + `Selection bridge: port ${listenPort} in use — tool will report no selection. ` + + `Set DRAWD_SELECTION_PORT to pick a free port.\n`, + ); + } else { + process.stderr.write(`Selection bridge error: ${err.message}\n`); + } + }); + + server.listen(listenPort, '127.0.0.1', () => { + process.stderr.write(`Selection bridge listening on http://localhost:${listenPort}\n`); + }); + + return { + get: () => lastSelection, + close: () => { + try { server.close(); } catch { /* ignore */ } + }, + }; +} + +function sendJson(res, status, body) { + const json = JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(json); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => { data += chunk; }); + req.on('end', () => { + try { + resolve(data ? JSON.parse(data) : {}); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} diff --git a/mcp-server/src/server.js b/mcp-server/src/server.js index 505d699..5e87e4f 100644 --- a/mcp-server/src/server.js +++ b/mcp-server/src/server.js @@ -13,6 +13,7 @@ import { modelTools, handleModelTool } from "./tools/model-tools.js"; import { annotationTools, handleAnnotationTool } from "./tools/annotation-tools.js"; import { commentTools, handleCommentTool } from "./tools/comment-tools.js"; import { generationTools, handleGenerationTool } from "./tools/generation-tools.js"; +import { selectionTools, handleSelectionTool } from "./tools/selection-tools.js"; const FILE_TOOL_NAMES = new Set(fileTools.map((t) => t.name)); const SCREEN_TOOL_NAMES = new Set(screenTools.map((t) => t.name)); @@ -23,6 +24,7 @@ const MODEL_TOOL_NAMES = new Set(modelTools.map((t) => t.name)); const ANNOTATION_TOOL_NAMES = new Set(annotationTools.map((t) => t.name)); const COMMENT_TOOL_NAMES = new Set(commentTools.map((t) => t.name)); const GENERATION_TOOL_NAMES = new Set(generationTools.map((t) => t.name)); +const SELECTION_TOOL_NAMES = new Set(selectionTools.map((t) => t.name)); // filePath is injected into every non-file tool so callers can establish // session context inline (auto-loaded once, then reused for the whole session). @@ -55,9 +57,10 @@ const ALL_TOOLS = [ ...withFilePath(annotationTools), ...withFilePath(commentTools), ...withFilePath(generationTools), + ...withFilePath(selectionTools), ]; -export function createServer(state, renderer) { +export function createServer(state, renderer, bridge) { const server = new Server( { name: "drawd-mcp", version: "1.0.0" }, { capabilities: { tools: {} } }, @@ -97,6 +100,8 @@ export function createServer(state, renderer) { result = handleCommentTool(name, args, state); } else if (GENERATION_TOOL_NAMES.has(name)) { result = handleGenerationTool(name, args, state); + } else if (SELECTION_TOOL_NAMES.has(name)) { + result = handleSelectionTool(name, args, state, bridge); } else { return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], diff --git a/mcp-server/src/tools/selection-tools.js b/mcp-server/src/tools/selection-tools.js new file mode 100644 index 0000000..0ec1d1e --- /dev/null +++ b/mcp-server/src/tools/selection-tools.js @@ -0,0 +1,160 @@ +const STALENESS_MS = 60_000; + +export const selectionTools = [ + { + name: "get_current_selection", + description: + "Return the element(s) the user currently has selected in the running Drawd browser app. " + + "Requires the Drawd app to be open and connected to the MCP selection bridge. " + + "Returns enriched objects (screen, sticky, connection, hotspot, screenGroup, comment) " + + "so the agent can act on them directly without asking the user for IDs. " + + "If no recent selection is available (app closed or user hasn't selected anything in the last 60s), " + + "returns { selection: null, reason, hint }.", + inputSchema: { + type: "object", + properties: { + includeDetails: { + type: "boolean", + description: "Include enriched object data (default: true). Set false to get only type + id entries.", + }, + }, + }, + }, +]; + +export function handleSelectionTool(name, args, state, bridge) { + if (name !== "get_current_selection") { + throw new Error(`Unknown selection tool: ${name}`); + } + + const snapshot = bridge ? bridge.get() : null; + + if (!snapshot) { + return { + selection: null, + reason: "no_recent_selection", + hint: + "The Drawd app has not reported a selection yet. Ask the user to open the flow in the Drawd app and click an element.", + }; + } + + const age = Date.now() - snapshot.receivedAt; + if (age > STALENESS_MS) { + return { + selection: null, + reason: "no_recent_selection", + hint: + "User hasn't selected anything recently — ask them to click an element in the Drawd app.", + lastReceivedAt: new Date(snapshot.receivedAt).toISOString(), + ageMs: age, + }; + } + + const items = Array.isArray(snapshot.payload?.items) ? snapshot.payload.items : []; + const includeDetails = args?.includeDetails !== false; + + const enriched = items + .map((item) => (includeDetails ? enrichItem(item, state) : { type: item.type, id: item.id })) + .filter(Boolean); + + return { + selection: enriched, + receivedAt: new Date(snapshot.receivedAt).toISOString(), + source: "live_bridge", + filePath: snapshot.payload?.filePath || null, + }; +} + +function enrichItem(item, state) { + if (!item || !item.type || !item.id) return null; + + switch (item.type) { + case "screen": { + const screen = state.screens.find((s) => s.id === item.id); + if (!screen) return { type: "screen", id: item.id, missing: true }; + return { + type: "screen", + id: screen.id, + name: screen.name, + description: screen.description || "", + status: screen.status || "new", + hotspotCount: (screen.hotspots || []).length, + }; + } + + case "sticky": { + const sticky = state.stickyNotes.find((n) => n.id === item.id); + if (!sticky) return { type: "sticky", id: item.id, missing: true }; + return { + type: "sticky", + id: sticky.id, + content: sticky.content || "", + color: sticky.color || null, + x: sticky.x, + y: sticky.y, + }; + } + + case "connection": { + const conn = state.connections.find((c) => c.id === item.id); + if (!conn) return { type: "connection", id: item.id, missing: true }; + return { + type: "connection", + id: conn.id, + fromScreenId: conn.fromScreenId, + toScreenId: conn.toScreenId, + label: conn.label || "", + action: conn.action || "navigate", + }; + } + + case "hotspot": { + const screenId = item.screenId || findHotspotScreenId(state, item.id); + const screen = screenId ? state.screens.find((s) => s.id === screenId) : null; + const hotspot = screen ? (screen.hotspots || []).find((h) => h.id === item.id) : null; + if (!hotspot) return { type: "hotspot", id: item.id, screenId: screenId || null, missing: true }; + return { + type: "hotspot", + id: hotspot.id, + screenId: screen.id, + label: hotspot.label || "", + action: hotspot.action || "navigate", + targetScreenId: hotspot.targetScreenId || null, + }; + } + + case "screenGroup": { + const group = state.screenGroups.find((g) => g.id === item.id); + if (!group) return { type: "screenGroup", id: item.id, missing: true }; + return { + type: "screenGroup", + id: group.id, + name: group.name, + screenIds: [...(group.screenIds || [])], + }; + } + + case "comment": { + const comment = state.comments.find((c) => c.id === item.id); + if (!comment) return { type: "comment", id: item.id, missing: true }; + return { + type: "comment", + id: comment.id, + text: comment.text || "", + targetType: comment.targetType || "screen", + targetId: comment.targetId || null, + resolved: !!comment.resolved, + }; + } + + default: + return { type: item.type, id: item.id, unknown: true }; + } +} + +function findHotspotScreenId(state, hotspotId) { + for (const screen of state.screens) { + if ((screen.hotspots || []).some((h) => h.id === hotspotId)) return screen.id; + } + return null; +} diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 400fbf0..1aa12d2 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -22,6 +22,7 @@ import { useCommentManager } from "./hooks/useCommentManager"; import { useInteractionCallbacks } from "./hooks/useInteractionCallbacks"; import { useDerivedCanvasState } from "./hooks/useDerivedCanvasState"; import { useTemplateInserter } from "./hooks/useTemplateInserter"; +import { useSelectionReporter } from "./hooks/useSelectionReporter"; import { TopBar } from "./components/TopBar"; import { Sidebar } from "./components/Sidebar"; import { StickyNoteSidebar } from "./components/StickyNoteSidebar"; @@ -291,6 +292,20 @@ export default function Drawd({ initialRoomCode }) { // Keep collab sync refs up to date hotspotInteractionRef.current = hotspotInteraction; + // ── MCP selection bridge reporter ──────────────────────────────────────── + useSelectionReporter({ + canvasSelection, + selectedScreen, + selectedStickyNote, + selectedScreenGroup, + selectedConnection, + selectedHotspots, + hotspotInteraction, + selectedCommentId, + screens, + filePath: connectedFileName, + }); + // ── Cross-concern callbacks ────────────────────────────────────────────────────────── const { onConnectionClick, onConnectionDoubleClick, onConnectComplete, diff --git a/src/hooks/useSelectionReporter.js b/src/hooks/useSelectionReporter.js new file mode 100644 index 0000000..2cf6b29 --- /dev/null +++ b/src/hooks/useSelectionReporter.js @@ -0,0 +1,152 @@ +import { useEffect, useRef } from "react"; + +const DEBOUNCE_MS = 150; +const DEFAULT_BRIDGE_URL = "http://localhost:3337/selection"; + +function resolveBridgeUrl() { + try { + return import.meta.env?.VITE_DRAWD_SELECTION_URL || DEFAULT_BRIDGE_URL; + } catch { + return DEFAULT_BRIDGE_URL; + } +} + +/** + * Reports the user's current canvas selection to the MCP selection bridge so + * the `get_current_selection` MCP tool can return it to AI agents. Fails + * silently when the bridge isn't reachable — the app works fine either way. + */ +export function useSelectionReporter({ + canvasSelection, + selectedScreen, + selectedStickyNote, + selectedScreenGroup, + selectedConnection, + selectedHotspots, + hotspotInteraction, + selectedCommentId, + screens, + filePath, +}) { + const timerRef = useRef(null); + const bridgeUrl = resolveBridgeUrl(); + + useEffect(() => { + const items = buildItems({ + canvasSelection, + selectedScreen, + selectedStickyNote, + selectedScreenGroup, + selectedConnection, + selectedHotspots, + hotspotInteraction, + selectedCommentId, + screens, + }); + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + const body = JSON.stringify({ items, filePath: filePath || null, at: Date.now() }); + try { + fetch(bridgeUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }).catch(() => { /* bridge not running — ignore */ }); + } catch { /* ignore */ } + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [ + canvasSelection, + selectedScreen, + selectedStickyNote, + selectedScreenGroup, + selectedConnection, + selectedHotspots, + hotspotInteraction, + selectedCommentId, + screens, + filePath, + bridgeUrl, + ]); +} + +function buildItems({ + canvasSelection, + selectedScreen, + selectedStickyNote, + selectedScreenGroup, + selectedConnection, + selectedHotspots, + hotspotInteraction, + selectedCommentId, + screens, +}) { + const seen = new Set(); + const items = []; + + const push = (entry) => { + const key = entry.type + ":" + entry.id; + if (seen.has(key)) return; + seen.add(key); + items.push(entry); + }; + + (canvasSelection || []).forEach((sel) => { + if (sel?.type && sel?.id) push({ type: sel.type, id: sel.id }); + }); + + const coerceId = (v) => { + if (!v) return null; + if (typeof v === "string") return v; + if (typeof v === "object") return v.id || null; + return null; + }; + + const screenId = coerceId(selectedScreen); + if (screenId) push({ type: "screen", id: screenId }); + + const stickyId = coerceId(selectedStickyNote); + if (stickyId) push({ type: "sticky", id: stickyId }); + + const groupId = coerceId(selectedScreenGroup); + if (groupId) push({ type: "screenGroup", id: groupId }); + + const connId = coerceId(selectedConnection); + if (connId) push({ type: "connection", id: connId }); + + if (selectedCommentId) push({ type: "comment", id: selectedCommentId }); + + (selectedHotspots || []).forEach((hs) => { + if (!hs) return; + const hotspotId = typeof hs === "string" ? hs : hs.hotspotId || hs.id; + if (!hotspotId) return; + const parentScreenId = + (typeof hs === "object" && (hs.screenId || hs.screen?.id)) || + findHotspotScreenId(screens, hotspotId); + push({ type: "hotspot", id: hotspotId, screenId: parentScreenId || null }); + }); + + // Single-hotspot selection lives on hotspotInteraction when mode is "selected". + if (hotspotInteraction?.mode === "selected" && hotspotInteraction.hotspotId) { + push({ + type: "hotspot", + id: hotspotInteraction.hotspotId, + screenId: hotspotInteraction.screenId || findHotspotScreenId(screens, hotspotInteraction.hotspotId), + }); + } + + return items; +} + +function findHotspotScreenId(screens, hotspotId) { + if (!Array.isArray(screens)) return null; + for (const screen of screens) { + if ((screen.hotspots || []).some((h) => h.id === hotspotId)) return screen.id; + } + return null; +} diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index f1b268e..ef05ee1 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -733,6 +733,7 @@ The MCP server exposes 27 tools organized by category: - **Annotation** — `create_sticky_note`, `create_screen_group`, `update_screen_group`, `delete_screen_group` - **Comments** — `list_comments`, `create_comment`, `update_comment`, `resolve_comment`, `delete_comment` - **Generation** — `validate_flow`, `generate_instructions`, `analyze_navigation` +- **Selection** — `get_current_selection` ### Creating screens from HTML @@ -768,6 +769,17 @@ A typical agent interaction looks like this: You can then open the saved `.drawd` file in Drawd to visually inspect the flow, adjust screen positions, refine hotspots, and regenerate instructions. +### Reading the user's current selection + +The `get_current_selection` tool lets an AI agent know which element(s) you currently have selected in the Drawd browser app — so you can say "update this screen" or "add a hotspot here" without pasting IDs. + +When the MCP server starts, it opens a tiny HTTP listener on `localhost:3337`. The Drawd app posts your selection to that listener any time it changes (screens, sticky notes, connections, hotspots, screen groups, comments). The tool reads the latest snapshot and returns enriched objects (names, descriptions, hotspot counts, etc.) that the agent can act on directly. + +> [!NOTE] +> The bridge runs automatically — there is no manual setup. If port 3337 is in use, set `DRAWD_SELECTION_PORT` before starting the MCP server. + +If the user hasn't interacted with the app in the last 60 seconds, the tool returns `{ selection: null, reason: "no_recent_selection" }` so the agent knows to ask instead of acting on stale state. + ### Pre-loading a flow Start the MCP server with an existing `.drawd` file pre-loaded: