Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion mcp-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
});
Expand Down
97 changes: 97 additions & 0 deletions mcp-server/src/selection-bridge.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
7 changes: 6 additions & 1 deletion mcp-server/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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).
Expand Down Expand Up @@ -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: {} } },
Expand Down Expand Up @@ -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}` }) }],
Expand Down
160 changes: 160 additions & 0 deletions mcp-server/src/tools/selection-tools.js
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions src/Drawd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading