feat: add test suite, CI/CD pipeline, setup wizard, and upstream feature ports (#6)

Establish comprehensive quality infrastructure for a project that previously
had zero tests, enabling confident refactoring and community contributions
with automated guardrails. Port upstream enhancements for font normalization,
image element support, and arrow binding preservation.

🏗️ Testing infrastructure:
- Unit tests for SQLite persistence layer and element validation helpers
- Integration tests for REST API, WebSocket broadcast, and arrow binding
- E2E tests with Playwright for canvas rendering and real-time sync
- Vitest + Playwright configuration with proper isolation

👷 CI/CD pipeline:
- Auto-versioning from conventional commits on push to main
- Auto-publish to NPM and Docker Hub on GitHub release
- Matrix testing across Node 18/20/22 with pinned dependencies
- Docker health check with diagnostic logging on failure
- Preserve rollup status checks for branch protection gates

📦 Developer experience:
- Interactive setup wizard for first-time configuration
- Canvas clear confirmation and scene description tools
- Frontend helpers extracted for testability

🔧 Upstream feature ports:
- Font family normalization (string names to numeric IDs)
- Image element support with file management API
- Arrow binding preservation through server round-trips
- Vite config fix for font subsetting worker chunk names
- Idempotent database initialization for standalone Docker mode

🐛 Docker fixes:
- Set EXCALIDRAW_DB_PATH in both Dockerfiles to writable /app/data/
- Make initDb() idempotent and closeDb() reset-safe for test isolation

🎯 Provides the safety net needed for rapid iteration — every PR is
validated across 120 test cases before merge, and releases are fully
automated from commit to published package.

Co-authored-by: sanjibdevnathlabs <devnath.sanjib@gmail.com>
This commit is contained in:
Sanjib Devnath
2026-03-13 12:08:07 +05:30
committed by GitHub
co-authored by sanjibdevnathlabs
parent 4c50472ee4
commit 63209f9d5a
28 changed files with 4850 additions and 713 deletions
+19
View File
@@ -44,6 +44,8 @@ function generateId(): string {
}
export function initDb(dbPath?: string): void {
if (db) return; // Already initialized
const resolvedPath = dbPath
|| process.env.EXCALIDRAW_DB_PATH
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
@@ -129,6 +131,11 @@ function runMigrations(): void {
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id);
CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type);
CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted);
@@ -501,9 +508,21 @@ export function bulkReplaceElements(elements: ServerElement[], projectId?: strin
return tx();
}
// ── Settings ──
export function getSetting(key: string): string | undefined {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value;
}
export function setSetting(key: string, value: string): void {
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
}
export function closeDb(): void {
if (db) {
db.close();
db = undefined as any;
logger.info('SQLite database closed');
}
}
+225 -41
View File
@@ -25,7 +25,9 @@ import {
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
validateElement
validateElement,
normalizeFontFamily,
files as globalFiles
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
@@ -63,6 +65,10 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true;
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
// API Response types
interface ApiResponse {
success: boolean;
@@ -247,7 +253,7 @@ const ElementSchema = z.object({
opacity: z.number().optional(),
text: z.string().optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional(),
strokeStyle: z.string().optional(),
@@ -258,6 +264,9 @@ const ElementSchema = z.object({
endElementId: z.string().optional(),
endArrowhead: z.string().optional(),
startArrowhead: z.string().optional(),
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
const ElementIdSchema = z.object({
@@ -409,7 +418,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -640,7 +649,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -666,10 +675,15 @@ const tools: Tool[] = [
},
{
name: 'clear_canvas',
description: 'Clear all elements from the canvas',
description: 'DESTRUCTIVE: Permanently deletes ALL elements from the canvas. Two-step process: (1) Call WITHOUT clearToken to get a preview of what will be deleted — present this to the user and ask for confirmation. (2) Call WITH the returned clearToken to execute the clear. Prefer placing new diagrams alongside existing ones (call describe_scene first).',
inputSchema: {
type: 'object',
properties: {}
properties: {
clearToken: {
type: 'string',
description: 'One-time token returned by the preview step. Pass this to confirm and execute the clear. Omit on first call to get the preview.'
}
}
}
},
{
@@ -973,11 +987,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const { startElementId, endElementId, id: customId, ...elementProps } = params;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
...elementProps,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
// Convert binding IDs to Excalidraw's start/end format
...(startElementId ? { start: { id: startElementId } } : {}),
...(endElementId ? { end: { id: endElementId } } : {}),
createdAt: new Date().toISOString(),
@@ -1020,10 +1035,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (!id) throw new Error('Element ID is required');
// Build update payload with timestamp and version increment
const normalizedFont = normalizeFontFamily(updates.fontFamily);
const updatePayload: Partial<ServerElement> & { id: string } = {
id,
...updates,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: rawPoints ? normalizePoints(rawPoints) : undefined,
updatedAt: new Date().toISOString()
};
@@ -1468,11 +1484,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
for (const elementData of params.elements) {
const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
...elementProps,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
// Convert binding IDs to Excalidraw's start/end format
...(startElementId ? { start: { id: startElementId } } : {}),
...(endElementId ? { end: { id: endElementId } } : {}),
createdAt: new Date().toISOString(),
@@ -1530,23 +1547,99 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}
case 'clear_canvas': {
logger.info('Clearing canvas via MCP');
const clearParams = z.object({ clearToken: z.string().optional() }).parse(args);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
if (!clearParams.clearToken) {
// Step 1: Preview — show what will be deleted and return a one-time token
const previewResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!previewResp.ok) throw new Error('Failed to fetch elements for preview');
const previewData = await previewResp.json() as ApiResponse;
const elements = previewData.elements || [];
const count = elements.length;
if (count === 0) {
return {
content: [{ type: 'text', text: 'The canvas is already empty. Nothing to clear.' }]
};
}
// Build a summary of what exists
const typeCounts: Record<string, number> = {};
for (const el of elements) {
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
}
const typesSummary = Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ');
// Generate one-time token
const token = `clr_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
pendingClearTokens.set(token, { expiresAt: Date.now() + CLEAR_TOKEN_TTL_MS, elementCount: count });
// Prune expired tokens
for (const [k, v] of pendingClearTokens) {
if (v.expiresAt < Date.now()) pendingClearTokens.delete(k);
}
return {
content: [{
type: 'text',
text: [
`⚠️ CLEAR CANVAS — confirmation required`,
``,
`This will permanently delete **${count} element${count !== 1 ? 's' : ''}**: ${typesSummary}`,
``,
`Ask the user: "Do you want me to clear all ${count} elements from the canvas?"`,
``,
`If the user confirms, call clear_canvas again with clearToken: "${token}"`,
`If the user declines, do NOT call clear_canvas again.`,
].join('\n')
}]
};
}
// Step 2: Execute clear with a valid token
const tokenData = pendingClearTokens.get(clearParams.clearToken);
if (!tokenData) {
return {
content: [{
type: 'text',
text: 'Clear canvas rejected: invalid or expired clearToken. Call clear_canvas without clearToken first to get a fresh preview and token.'
}],
isError: true
};
}
if (tokenData.expiresAt < Date.now()) {
pendingClearTokens.delete(clearParams.clearToken);
return {
content: [{
type: 'text',
text: 'Clear canvas rejected: clearToken has expired. Call clear_canvas without clearToken to get a new one.'
}],
isError: true
};
}
// Token valid — consume it and clear
pendingClearTokens.delete(clearParams.clearToken);
logger.info('Clearing canvas via MCP (user confirmed via token)');
const clearResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
method: 'DELETE',
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
if (!clearResponse.ok) {
throw new Error(`Failed to clear canvas: ${clearResponse.status} ${clearResponse.statusText}`);
}
const data = await response.json() as ApiResponse;
const clearData = await clearResponse.json() as ApiResponse;
return {
content: [{
type: 'text',
text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
text: `Canvas cleared.\n\n${JSON.stringify(clearData, null, 2)}`
}]
};
}
@@ -1568,6 +1661,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const data = await response.json() as ApiResponse;
const sceneElements = data.elements || [];
// Collect files from the in-memory store
const exportFiles: Record<string, any> = {};
for (const [id, file] of globalFiles) {
exportFiles[id] = file;
}
const excalidrawScene = {
type: 'excalidraw',
version: 2,
@@ -1576,7 +1675,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
appState: {
viewBackgroundColor: '#ffffff',
gridSize: null
}
},
files: exportFiles
};
const jsonString = JSON.stringify(excalidrawScene, null, 2);
@@ -1587,7 +1687,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
return {
content: [{
type: 'text',
text: `Scene exported to ${safePath} (${sceneElements.length} elements)`
text: `Scene exported to ${safePath} (${sceneElements.length} elements, ${Object.keys(exportFiles).length} files)`
}]
};
}
@@ -1629,6 +1729,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
throw new Error('No elements found in the import data');
}
// Import files if present
const importedFiles = sceneData.files;
if (importedFiles && typeof importedFiles === 'object') {
for (const [id, fileData] of Object.entries(importedFiles)) {
const file = fileData as any;
globalFiles.set(id, {
id,
mimeType: file.mimeType || 'image/png',
dataURL: file.dataURL,
created: file.created || Date.now(),
});
}
// Push files to canvas server
try {
await fetch(`${EXPRESS_SERVER_URL}/api/files`, {
method: 'POST',
headers: canvasHeaders(),
body: JSON.stringify({ files: importedFiles })
});
} catch {}
}
if (params.mode === 'replace') {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
}
@@ -1814,7 +1936,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (allElements.length === 0) {
return {
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }]
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.\n\nSuggested placement for a new diagram: x=0, y=0' }]
};
}
@@ -1824,7 +1946,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
}
// Bounding box
// Overall bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of allElements) {
minX = Math.min(minX, el.x);
@@ -1833,6 +1955,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
maxY = Math.max(maxY, el.y + (el.height || 0));
}
// ── Diagram zone detection ──
// Build a map of groupId → elements
const groupMap: Record<string, ServerElement[]> = {};
const ungroupedElements: ServerElement[] = [];
for (const el of allElements) {
if (el.groupIds && el.groupIds.length > 0) {
for (const gid of el.groupIds) {
if (!groupMap[gid]) groupMap[gid] = [];
groupMap[gid]!.push(el);
}
} else {
ungroupedElements.push(el);
}
}
interface DiagramZone {
groupId: string;
label: string | null;
bbox: { minX: number; minY: number; maxX: number; maxY: number };
elementCount: number;
}
const zones: DiagramZone[] = [];
for (const [gid, elements] of Object.entries(groupMap)) {
let zMinX = Infinity, zMinY = Infinity, zMaxX = -Infinity, zMaxY = -Infinity;
let label: string | null = null;
for (const el of elements) {
zMinX = Math.min(zMinX, el.x);
zMinY = Math.min(zMinY, el.y);
zMaxX = Math.max(zMaxX, el.x + (el.width || 0));
zMaxY = Math.max(zMaxY, el.y + (el.height || 0));
// Use the first text element or label as the zone name
if (!label) {
if (el.type === 'text' && el.text) label = el.text;
else if (el.label?.text) label = el.label.text;
}
}
zones.push({
groupId: gid,
label,
bbox: { minX: zMinX, minY: zMinY, maxX: zMaxX, maxY: zMaxY },
elementCount: elements.length,
});
}
// Suggest next placement: 300px to the right of the overall bounding box
const suggestedX = Math.round(maxX + 300);
const suggestedY = Math.round(minY);
// Build element descriptions sorted top-to-bottom, left-to-right
const sorted = [...allElements].sort((a, b) => {
const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50);
@@ -1879,7 +2053,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
lines.push(`## Canvas Description`);
lines.push(`Total elements: ${allElements.length}`);
lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`);
lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
lines.push(`Canvas bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
// Diagram zones section
if (zones.length > 0) {
lines.push('');
lines.push('### Diagram Zones (grouped):');
for (const zone of zones) {
const w = Math.round(zone.bbox.maxX - zone.bbox.minX);
const h = Math.round(zone.bbox.maxY - zone.bbox.minY);
const name = zone.label ? `"${zone.label}"` : '(unnamed)';
lines.push(` Group ${zone.groupId}: ${name} | bbox (${Math.round(zone.bbox.minX)}, ${Math.round(zone.bbox.minY)}) to (${Math.round(zone.bbox.maxX)}, ${Math.round(zone.bbox.maxY)}) = ${w}x${h} | ${zone.elementCount} elements`);
}
}
if (ungroupedElements.length > 0 && zones.length > 0) {
lines.push(` + ${ungroupedElements.length} ungrouped elements`);
}
lines.push('');
lines.push(`### Suggested placement for new diagram: x=${suggestedX}, y=${suggestedY}`);
lines.push('');
lines.push('### Elements (top-to-bottom, left-to-right):');
lines.push(...elementDescs);
@@ -1890,23 +2084,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
lines.push(...connectionDescs);
}
// Groups
const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0);
if (groupedElements.length > 0) {
const groupMap: Record<string, string[]> = {};
for (const el of groupedElements) {
for (const gid of (el.groupIds || [])) {
if (!groupMap[gid]) groupMap[gid] = [];
groupMap[gid]!.push(el.id);
}
}
lines.push('');
lines.push('### Groups:');
for (const [gid, ids] of Object.entries(groupMap)) {
lines.push(` Group ${gid}: [${ids.join(', ')}]`);
}
}
return {
content: [{ type: 'text', text: lines.join('\n') }]
};
@@ -2533,10 +2710,17 @@ if (process.env.DEBUG === 'true') {
// Start the server if this file is run directly
if (fileURLToPath(import.meta.url) === process.argv[1]) {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
if (process.argv[2] === 'setup') {
import('./setup.js').then(m => m.runSetup()).catch(error => {
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
process.exit(1);
});
} else {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
}
}
export default runServer;
+137 -10
View File
@@ -18,10 +18,13 @@ import {
BatchCreatedMessage,
SyncStatusMessage,
InitialElementsMessage,
Snapshot
Snapshot,
normalizeFontFamily,
ExcalidrawFile,
files
} from './types.js';
import * as store from './db.js';
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -86,6 +89,15 @@ wss.on('connection', (ws: WebSocket) => {
elements: store.getAllElements()
};
ws.send(JSON.stringify(initialMessage));
// Send any stored files (image data)
if (files.size > 0) {
const allFiles: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
allFiles[id] = file;
}
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
}
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
@@ -108,7 +120,7 @@ wss.on('connection', (ws: WebSocket) => {
// Schema validation
const CreateElementSchema = z.object({
id: z.string().optional(), // Allow passing ID for MCP sync
id: z.string().optional(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(),
y: z.number(),
@@ -121,11 +133,12 @@ const CreateElementSchema = z.object({
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
originalText: z.string().optional(),
label: z.object({
text: z.string()
}).optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional(),
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
@@ -136,7 +149,14 @@ const CreateElementSchema = z.object({
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
startBinding: z.any().nullable().optional(),
endBinding: z.any().nullable().optional(),
boundElements: z.any().nullable().optional(),
elbowed: z.boolean().optional(),
// Image element properties
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
const UpdateElementSchema = z.object({
@@ -153,11 +173,12 @@ const UpdateElementSchema = z.object({
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
originalText: z.string().optional(),
label: z.object({
text: z.string()
}).optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional(),
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
@@ -170,7 +191,13 @@ const UpdateElementSchema = z.object({
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
startBinding: z.any().nullable().optional(),
endBinding: z.any().nullable().optional(),
boundElements: z.any().nullable().optional(),
elbowed: z.boolean().optional(),
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
// API Routes
@@ -202,9 +229,11 @@ app.post('/api/elements', (req: Request, res: Response) => {
logger.info('Creating element via API', { type: params.type });
const id = params.id || generateId();
const normalizedFont = normalizeFontFamily(params.fontFamily);
const element: ServerElement = {
id,
...params,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
@@ -253,9 +282,11 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
});
}
const normalizedFont = normalizeFontFamily(updates.fontFamily);
const updatedElement: ServerElement = {
...existingElement,
...updates,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
updatedAt: new Date().toISOString(),
version: (existingElement.version || 0) + 1
};
@@ -526,11 +557,9 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
el.y = finalStart.y;
el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
// Remove start/end refs (they were used for computation only)
delete (el as any).start;
delete (el as any).end;
// Set binding metadata for Excalidraw
// Keep start/end refs on the element — the frontend's
// convertToExcalidrawElements uses them to compute proper bindings
// (focus, gap, fixedPoint). Also set basic binding metadata for export.
if (startEl) {
(el as any).startBinding = {
elementId: startEl.id,
@@ -566,9 +595,11 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData);
const id = params.id || generateId();
const normalizedFont = normalizeFontFamily(params.fontFamily);
const element: ServerElement = {
id,
...params,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
@@ -714,6 +745,70 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}
});
// ── Files API (image element data) ──
// Get all files
app.get('/api/files', (_req: Request, res: Response) => {
try {
const allFiles: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
allFiles[id] = file;
}
res.json({ success: true, files: allFiles });
} catch (error) {
logger.error('Error fetching files:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Add files (image data)
app.post('/api/files', (req: Request, res: Response) => {
try {
const incoming = req.body.files;
if (!incoming || typeof incoming !== 'object') {
return res.status(400).json({ success: false, error: 'files object is required' });
}
const addedIds: string[] = [];
for (const [id, fileData] of Object.entries(incoming)) {
const file = fileData as ExcalidrawFile;
files.set(id, {
id,
mimeType: file.mimeType || 'image/png',
dataURL: file.dataURL,
created: file.created || Date.now(),
});
addedIds.push(id);
}
broadcast({
type: 'files_added',
files: incoming
});
res.json({ success: true, addedIds, count: addedIds.length });
} catch (error) {
logger.error('Error adding files:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
// Delete a file
app.delete('/api/files/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!files.has(id!)) {
return res.status(404).json({ success: false, error: `File ${id} not found` });
}
files.delete(id!);
broadcast({ type: 'file_deleted', fileId: id });
res.json({ success: true, message: `File ${id} deleted` });
} catch (error) {
logger.error('Error deleting file:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Image export: request (MCP -> Express -> WebSocket -> Frontend)
interface PendingExport {
resolve: (data: { format: string; data: string }) => void;
@@ -1052,6 +1147,34 @@ app.put('/api/tenant/active', (req: Request, res: Response) => {
}
});
// ── Settings API ──
app.get('/api/settings/:key', (req: Request, res: Response) => {
try {
const { key } = req.params;
const value = store.getSetting(key!);
res.json({ success: true, key, value: value ?? null });
} catch (error) {
logger.error('Error reading setting:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.put('/api/settings/:key', (req: Request, res: Response) => {
try {
const { key } = req.params;
const { value } = req.body;
if (value === undefined || value === null) {
return res.status(400).json({ success: false, error: 'value is required' });
}
store.setSetting(key!, String(value));
res.json({ success: true, key, value: String(value) });
} catch (error) {
logger.error('Error writing setting:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
@@ -1099,6 +1222,10 @@ export function isCanvasServerOwned(): boolean {
}
export async function startCanvasServer(): Promise<void> {
// Ensure the database is initialized when running standalone (e.g. Docker: `node dist/server.js`).
// When launched via index.ts (MCP entry point), initDb() is a no-op on the second call.
initDb();
// Pre-flight: check if an existing healthy canvas server is already on this port.
// We do this BEFORE calling httpServer.listen() because Node's listen() can emit
// EADDRINUSE as an uncaught exception that bypasses our error handler.
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/**
* Interactive setup wizard for mcp-excalidraw-local.
* Runs via: npx @sanjibdevnath/mcp-excalidraw-local setup
*
* Uses only Node.js built-ins — no third-party dependencies.
* Every phase is optional and skippable.
*/
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ── Helpers ──────────────────────────────────────────────────
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const RESET = '\x1b[0m';
function ok(msg: string) { process.stdout.write(` ${GREEN}${RESET} ${msg}\n`); }
function fail(msg: string) { process.stdout.write(` ${RED}${RESET} ${msg}\n`); }
function warn(msg: string) { process.stdout.write(` ${YELLOW}${RESET} ${msg}\n`); }
function info(msg: string) { process.stdout.write(` ${msg}\n`); }
function heading(phase: string, title: string) {
process.stdout.write(`\n ${BOLD}[${phase}] ${title}${RESET}\n`);
}
function ask(rl: readline.Interface, prompt: string): Promise<string> {
return new Promise(resolve => rl.question(` ${prompt}`, resolve));
}
async function confirm(rl: readline.Interface, prompt: string, defaultYes = true): Promise<boolean> {
const hint = defaultYes ? '[Y/n]' : '[y/N]';
const answer = (await ask(rl, `${prompt} ${hint}: `)).trim().toLowerCase();
if (answer === '') return defaultYes;
return answer === 'y' || answer === 'yes';
}
// ── Agent Definitions ────────────────────────────────────────
interface AgentDef {
name: string;
detectPaths: string[];
skillBasePaths: { global: string; local: string };
mcpConfigType: 'json-file' | 'cli-command';
mcpConfigPath?: string;
mcpCliCommand?: string;
}
function getAgents(): AgentDef[] {
const home = os.homedir();
return [
{
name: 'Cursor',
detectPaths: [path.join(home, '.cursor')],
skillBasePaths: {
global: path.join(home, '.cursor', 'skills'),
local: path.join(process.cwd(), '.cursor', 'skills'),
},
mcpConfigType: 'json-file',
mcpConfigPath: path.join(home, '.cursor', 'mcp.json'),
},
{
name: 'Claude Code',
detectPaths: [path.join(home, '.claude')],
skillBasePaths: {
global: path.join(home, '.claude', 'skills'),
local: path.join(process.cwd(), '.claude', 'skills'),
},
mcpConfigType: 'cli-command',
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local',
},
{
name: 'Codex CLI',
detectPaths: [path.join(home, '.codex')],
skillBasePaths: {
global: path.join(home, '.codex', 'skills'),
local: path.join(process.cwd(), '.codex', 'skills'),
},
mcpConfigType: 'json-file',
mcpConfigPath: path.join(home, '.codex', 'mcp.json'),
},
];
}
function detectInstalledAgents(): AgentDef[] {
return getAgents().filter(a => a.detectPaths.some(p => fs.existsSync(p)));
}
// ── Phase 1: Environment Check ──────────────────────────────
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
heading('1/3', 'Environment');
let allOk = true;
// Node.js version
const nodeVersion = process.version;
const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
if (major >= 18) {
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
} else {
fail(`Node.js ${nodeVersion} — requires >= 18.0.0`);
allOk = false;
}
// better-sqlite3 bindings
try {
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
ok('better-sqlite3 bindings ........... OK');
} catch {
fail('better-sqlite3 bindings ........... FAILED');
const doFix = await confirm(rl, 'Native module needs rebuild. Fix now?');
if (doFix) {
try {
info(`${DIM}Running npm rebuild better-sqlite3...${RESET}`);
execSync('npm rebuild better-sqlite3', {
stdio: 'inherit',
cwd: path.resolve(__dirname, '..'),
});
// Verify
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
ok('Rebuild successful');
} catch {
fail('Rebuild failed. Try manually:');
info(` cd ${path.resolve(__dirname, '..')}`);
info(' npm rebuild better-sqlite3');
info('');
info('Prerequisites:');
if (process.platform === 'darwin') {
info(' xcode-select --install');
} else if (process.platform === 'linux') {
info(' sudo apt install build-essential python3');
} else {
info(' npm install --global windows-build-tools');
}
allOk = false;
}
} else {
info('Manual fix: npm rebuild better-sqlite3');
allOk = false;
}
}
// Frontend build
const frontendIndex = path.resolve(__dirname, '..', 'dist', 'frontend', 'index.html');
if (fs.existsSync(frontendIndex)) {
ok('Frontend build .................... OK');
} else {
warn('Frontend build .................... NOT FOUND');
info(`Expected: ${frontendIndex}`);
info('Run: npm run build');
}
return allOk;
}
// ── Phase 2: Skill Installation ─────────────────────────────
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
heading('2/3', 'Agent Skill');
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
if (!wantSkill) {
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually if needed)${RESET}`);
return;
}
const agents = detectInstalledAgents();
if (agents.length === 0) {
warn('No supported agents detected (Cursor, Claude Code, Codex CLI).');
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually when ready)${RESET}`);
return;
}
process.stdout.write('\n Detected agents:\n');
agents.forEach((a, i) => {
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${a.name}\n`);
});
const selection = await ask(rl, "Which agents? (comma-separated, 'all', or 'skip'): ");
const trimmed = selection.trim().toLowerCase();
if (trimmed === 'skip' || trimmed === '') return;
let selectedAgents: AgentDef[];
if (trimmed === 'all') {
selectedAgents = agents;
} else {
const indices = trimmed.split(',').map(s => parseInt(s.trim(), 10) - 1);
selectedAgents = indices
.filter(i => i >= 0 && i < agents.length)
.map(i => agents[i]!);
}
if (selectedAgents.length === 0) {
warn('No valid agents selected.');
return;
}
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
if (!fs.existsSync(skillSource)) {
fail(`Skill source not found at ${skillSource}`);
return;
}
for (const agent of selectedAgents) {
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
const destBase = agent.skillBasePaths[scope];
const destDir = path.join(destBase, 'excalidraw-skill');
try {
fs.mkdirSync(destDir, { recursive: true });
copyDirSync(skillSource, destDir);
ok(`Installed to ${destDir}`);
} catch (err) {
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
}
}
}
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// ── Phase 3: MCP Configuration ──────────────────────────────
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
heading('3/3', 'MCP Configuration');
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
if (!wantConfig) {
printManualConfig();
return;
}
const agents = detectInstalledAgents();
if (agents.length === 0) {
warn('No supported agents detected.');
printManualConfig();
return;
}
for (const agent of agents) {
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
const doIt = await confirm(rl, `${agent.name} — add to ${agent.mcpConfigPath}?`);
if (!doIt) {
info(`${DIM}Skipped. Add manually later.${RESET}`);
continue;
}
try {
mergeJsonConfig(agent.mcpConfigPath);
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
} catch (err) {
fail(`Failed: ${(err as Error).message}`);
info('Add manually:');
printManualConfig();
}
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
const doIt = await confirm(rl, `${agent.name} — register via CLI?`);
if (!doIt) {
info(`${DIM}Skipped.${RESET}`);
continue;
}
try {
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`);
} catch (err) {
fail(`CLI registration failed: ${(err as Error).message}`);
info('Register manually:');
info(` ${agent.mcpCliCommand}`);
}
}
}
}
function mergeJsonConfig(configPath: string): void {
const mcpEntry = {
command: 'npx',
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'],
env: { CANVAS_PORT: '3000' },
};
let existing: any = {};
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf-8');
existing = JSON.parse(raw);
}
if (!existing.mcpServers) {
existing.mcpServers = {};
}
if (existing.mcpServers['excalidraw-canvas']) {
process.stdout.write(` ${YELLOW}Entry 'excalidraw-canvas' already exists — overwriting.${RESET}\n`);
}
existing.mcpServers['excalidraw-canvas'] = mcpEntry;
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
}
function printManualConfig(): void {
process.stdout.write(`
Manual config (JSON):
${DIM}{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": { "CANVAS_PORT": "3000" }
}
}
}${RESET}
`);
}
// ── Main ─────────────────────────────────────────────────────
export async function runSetup(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Setup${RESET}\n`);
try {
await phaseEnvironment(rl);
await phaseSkillInstall(rl);
await phaseMcpConfig(rl);
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
} finally {
rl.close();
}
}
+53 -3
View File
@@ -106,7 +106,7 @@ export interface ExcalidrawBinding {
fixedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw';
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'image';
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
@@ -116,7 +116,8 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
ARROW: 'arrow',
TEXT: 'text',
FREEDRAW: 'freedraw',
LINE: 'line'
LINE: 'line',
IMAGE: 'image'
} as const;
// Server-side element with metadata
@@ -136,9 +137,16 @@ export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
text: string;
};
points?: any;
originalText?: string;
// Arrow element binding: connect arrows to shapes by element ID
start?: { id: string };
end?: { id: string };
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
// Image element properties
fileId?: string;
status?: string;
scale?: [number, number];
}
// API Response types
@@ -183,7 +191,9 @@ export type WebSocketMessageType =
| 'canvas_cleared'
| 'export_image_request'
| 'set_viewport'
| 'tenant_switched';
| 'tenant_switched'
| 'files_added'
| 'file_deleted';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
@@ -289,6 +299,46 @@ export interface Snapshot {
createdAt: string;
}
// Excalidraw file (image) data — stored in-memory alongside element data
export interface ExcalidrawFile {
mimeType: string;
id: string;
dataURL: string;
created: number;
lastRetrieved?: number;
}
// In-memory file storage (image files are too large for SQLite row storage)
export const files = new Map<string, ExcalidrawFile>();
// Font family normalization: Excalidraw expects numeric IDs, but agents
// often send string names. Map common names to their numeric equivalents.
const FONT_FAMILY_MAP: Record<string, number> = {
'virgil': 1,
'hand-drawn': 1,
'excalifont': 1,
'helvetica': 2,
'arial': 2,
'sans-serif': 2,
'cascadia': 3,
'monospace': 3,
'courier': 3,
'comic shanns': 4,
'comic sans': 4,
'liberation sans': 5,
'nunito': 6,
'lilita one': 7,
};
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === 'number') return value;
const mapped = FONT_FAMILY_MAP[value.toLowerCase().trim()];
if (mapped !== undefined) return mapped;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? 1 : parsed;
}
// Storage is now handled by src/db.ts (SQLite).
// The Map exports below are kept only for backward compatibility with
// standalone server.ts usage; they are NOT used when the DB is active.