feat: add SQLite persistence, multi-tenancy, auto-sync, and CI/Docker improvements (#1)

Replace in-memory storage with SQLite (WAL mode), add workspace-based
multi-tenancy with auto-detection via server.listRoots(), and embed the
canvas server into the MCP process for single-process operation.

🔧 Core enhancements:
- SQLite persistence with versioning, element history, and search
- Multi-tenancy: isolated canvases per workspace (SHA-256 tenant IDs)
- Embedded canvas lifecycle (single node process starts MCP + canvas)
- Auto-sync with 3s debounce and manual override toggle
- Configurable canvas port via CANVAS_PORT env var
- 6 new MCP tools (search, history, tenants, projects)
- Workspace switcher UI with dropdown search
- Sync normalization to prevent bound-text breakage on reload

🐳 Docker & CI improvements:
- BuildKit cache mounts for faster npm installs across builds
- Skip native compilation in frontend-builder stage (--ignore-scripts)
- Build only linux/amd64 on PRs, multi-arch on push to main
- Docker Hub registry with proper build tools for better-sqlite3
- CI and Docker status check gates (github/ci-status-check, github/docker-build-check)

📦 Package & publishing:
- Renamed to @sanjibdevnath/mcp-excalidraw-local (v3.0.0)
- Updated npm-publish workflow for scoped package
- Updated bin entry, keywords, and files list

📝 Documentation:
- README with UI screenshots, architecture diagram, and full feature docs
- Updated agent skill with 32-tool cheatsheet and workflow playbooks
- Fork attribution and upstream comparison table

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sanjib Devnath
2026-02-19 10:17:29 +05:30
committed by GitHub
co-authored by Cursor
parent 9883dce220
commit 7143b5e41e
23 changed files with 8278 additions and 923 deletions
+509
View File
@@ -0,0 +1,509 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import os from 'os';
import logger from './utils/logger.js';
import type { ServerElement, Snapshot } from './types.js';
export interface Tenant {
id: string;
name: string;
workspace_path: string;
created_at: string;
last_accessed_at: string;
}
export interface Project {
id: string;
name: string;
description: string | null;
tenant_id: string;
created_at: string;
updated_at: string;
}
export interface ElementVersion {
id: number;
element_id: string;
project_id: string;
version: number;
data: ServerElement;
operation: 'create' | 'update' | 'delete';
created_at: string;
}
const DEFAULT_PROJECT_ID = 'default';
const DEFAULT_TENANT_ID = 'default';
let db: Database.Database;
let activeTenantId: string = DEFAULT_TENANT_ID;
let activeProjectId: string = DEFAULT_PROJECT_ID;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
export function initDb(dbPath?: string): void {
const resolvedPath = dbPath
|| process.env.EXCALIDRAW_DB_PATH
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(resolvedPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('foreign_keys = ON');
runMigrations();
// Ensure default tenant exists
const defaultTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
if (!defaultTenant) {
const now = new Date().toISOString();
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
);
}
// Ensure default project exists and is linked to default tenant
const defaultProject = db.prepare('SELECT id FROM projects WHERE id = ?').get(DEFAULT_PROJECT_ID);
if (!defaultProject) {
db.prepare('INSERT INTO projects (id, name, description, tenant_id) VALUES (?, ?, ?, ?)').run(
DEFAULT_PROJECT_ID, 'Default', 'Default project', DEFAULT_TENANT_ID
);
}
logger.info(`SQLite database initialized at ${resolvedPath}`);
}
function runMigrations(): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL,
data TEXT NOT NULL,
label_text TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS element_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
element_id TEXT NOT NULL,
project_id TEXT NOT NULL,
version INTEGER NOT NULL,
data TEXT NOT NULL,
operation TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
elements TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, name)
);
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);
CREATE INDEX IF NOT EXISTS idx_versions_element ON element_versions(element_id);
CREATE INDEX IF NOT EXISTS idx_versions_project ON element_versions(project_id, created_at);
`);
// FTS table
const ftsExists = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='elements_fts'"
).get();
if (!ftsExists) {
db.exec(`
CREATE VIRTUAL TABLE elements_fts USING fts5(
element_id,
label_text,
type
);
`);
}
// Migration: add tenant_id to projects if it doesn't exist (upgrading from older schema)
const cols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
const hasTenantCol = cols.some(c => c.name === 'tenant_id');
if (!hasTenantCol) {
db.exec(`ALTER TABLE projects ADD COLUMN tenant_id TEXT REFERENCES tenants(id)`);
logger.info('Migrated: added tenant_id column to projects');
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
// Migration: assign orphan projects (no tenant_id) to default tenant
const orphans = db.prepare('SELECT id FROM projects WHERE tenant_id IS NULL').all() as { id: string }[];
if (orphans.length > 0) {
// Ensure default tenant exists for migration
const defTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
if (!defTenant) {
const now = new Date().toISOString();
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
);
}
db.prepare('UPDATE projects SET tenant_id = ? WHERE tenant_id IS NULL').run(DEFAULT_TENANT_ID);
logger.info(`Migrated: assigned ${orphans.length} orphan projects to default tenant`);
}
}
function extractLabelText(element: ServerElement): string | null {
if (element.label?.text) return element.label.text;
if (element.text) return element.text;
return null;
}
// Resolve effective project ID: explicit override > in-memory active
function pid(override?: string): string {
return override ?? activeProjectId;
}
// Given a tenant ID, return its default project (creating one if needed)
export function getDefaultProjectForTenant(tenantId: string): string {
const row = db.prepare(
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
).get(tenantId) as { id: string } | undefined;
if (row) return row.id;
const id = `${tenantId}-default`;
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, 'Default', 'Default project', tenantId, now, now);
return id;
}
// ── Element CRUD ──
export function getElement(id: string, projectId?: string): ServerElement | undefined {
const row = db.prepare(
'SELECT data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, pid(projectId)) as { data: string } | undefined;
return row ? JSON.parse(row.data) : undefined;
}
export function hasElement(id: string, projectId?: string): boolean {
const row = db.prepare(
'SELECT 1 FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, pid(projectId));
return !!row;
}
export function setElement(id: string, element: ServerElement, projectId?: string): void {
const p = pid(projectId);
const now = new Date().toISOString();
const data = JSON.stringify(element);
const labelText = extractLabelText(element);
const existing = db.prepare(
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
).get(id, p) as { version: number; is_deleted: number } | undefined;
if (existing) {
const newVersion = existing.is_deleted ? 1 : (existing.version + 1);
db.prepare(`
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0
WHERE id = ? AND project_id = ?
`).run(element.type, data, labelText, now, newVersion, id, p);
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
updateFts(id, labelText, element.type);
} else {
db.prepare(`
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`).run(id, p, element.type, data, labelText, now, now);
recordVersion(id, 1, data, 'create', p);
insertFts(id, labelText, element.type);
}
}
export function deleteElement(id: string, projectId?: string): boolean {
const p = pid(projectId);
const existing = db.prepare(
'SELECT version, data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, p) as { version: number; data: string } | undefined;
if (!existing) return false;
const newVersion = existing.version + 1;
db.prepare(`
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
WHERE id = ? AND project_id = ?
`).run(newVersion, new Date().toISOString(), id, p);
recordVersion(id, newVersion, existing.data, 'delete', p);
deleteFts(id);
return true;
}
export function getAllElements(projectId?: string): ServerElement[] {
const rows = db.prepare(
'SELECT data FROM elements WHERE project_id = ? AND is_deleted = 0'
).all(pid(projectId)) as { data: string }[];
return rows.map(r => JSON.parse(r.data));
}
export function getElementCount(projectId?: string): number {
const row = db.prepare(
'SELECT COUNT(*) as count FROM elements WHERE project_id = ? AND is_deleted = 0'
).get(pid(projectId)) as { count: number };
return row.count;
}
export function clearElements(projectId?: string): number {
const p = pid(projectId);
const now = new Date().toISOString();
const elements = getAllElements(p);
const stmt = db.prepare(`
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
WHERE project_id = ? AND is_deleted = 0
`);
const clearTx = db.transaction(() => {
const info = stmt.run(now, p);
for (const el of elements) {
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
deleteFts(el.id);
}
return info.changes;
});
return clearTx() as number;
}
export function queryElements(type?: string, filter?: Record<string, any>, projectId?: string): ServerElement[] {
let elements = getAllElements(projectId);
if (type) {
elements = elements.filter(el => el.type === type);
}
if (filter) {
elements = elements.filter(el => {
return Object.entries(filter).every(([key, value]) => {
return (el as any)[key] === value;
});
});
}
return elements;
}
export function searchElements(query: string, projectId?: string): ServerElement[] {
const rows = db.prepare(`
SELECT e.data FROM elements e
INNER JOIN elements_fts fts ON fts.element_id = e.id
WHERE elements_fts MATCH ? AND e.project_id = ? AND e.is_deleted = 0
`).all(query, pid(projectId)) as { data: string }[];
return rows.map(r => JSON.parse(r.data));
}
// ── FTS helpers ──
function insertFts(elementId: string, labelText: string | null, type: string): void {
db.prepare('INSERT INTO elements_fts (element_id, label_text, type) VALUES (?, ?, ?)').run(
elementId, labelText || '', type
);
}
function updateFts(elementId: string, labelText: string | null, type: string): void {
deleteFts(elementId);
insertFts(elementId, labelText, type);
}
function deleteFts(elementId: string): void {
db.prepare("DELETE FROM elements_fts WHERE element_id = ?").run(elementId);
}
// ── Version history ──
function recordVersion(elementId: string, version: number, data: string, operation: string, projectId?: string): void {
db.prepare(`
INSERT INTO element_versions (element_id, project_id, version, data, operation)
VALUES (?, ?, ?, ?, ?)
`).run(elementId, pid(projectId), version, data, operation);
}
export function getElementHistory(elementId: string, limit: number = 50, projectId?: string): ElementVersion[] {
const rows = db.prepare(`
SELECT id, element_id, project_id, version, data, operation, created_at
FROM element_versions WHERE element_id = ? AND project_id = ?
ORDER BY created_at DESC LIMIT ?
`).all(elementId, pid(projectId), limit) as any[];
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
}
export function getProjectHistory(limit: number = 100, projectId?: string): ElementVersion[] {
const rows = db.prepare(`
SELECT id, element_id, project_id, version, data, operation, created_at
FROM element_versions WHERE project_id = ?
ORDER BY created_at DESC LIMIT ?
`).all(pid(projectId), limit) as any[];
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
}
// ── Snapshots ──
export function saveSnapshot(name: string, elements: ServerElement[], projectId?: string): void {
const data = JSON.stringify(elements);
db.prepare(`
INSERT OR REPLACE INTO snapshots (project_id, name, elements, created_at)
VALUES (?, ?, ?, datetime('now'))
`).run(pid(projectId), name, data);
}
export function getSnapshot(name: string, projectId?: string): Snapshot | undefined {
const row = db.prepare(
'SELECT name, elements, created_at FROM snapshots WHERE name = ? AND project_id = ?'
).get(name, pid(projectId)) as { name: string; elements: string; created_at: string } | undefined;
if (!row) return undefined;
return { name: row.name, elements: JSON.parse(row.elements), createdAt: row.created_at };
}
export function listSnapshots(projectId?: string): { name: string; elementCount: number; createdAt: string }[] {
const rows = db.prepare(
'SELECT name, elements, created_at FROM snapshots WHERE project_id = ? ORDER BY created_at DESC'
).all(pid(projectId)) as { name: string; elements: string; created_at: string }[];
return rows.map(r => ({
name: r.name,
elementCount: (JSON.parse(r.elements) as any[]).length,
createdAt: r.created_at
}));
}
// ── Tenants ──
export function ensureTenant(id: string, name: string, workspacePath: string): Tenant {
const now = new Date().toISOString();
const existing = db.prepare('SELECT * FROM tenants WHERE id = ?').get(id) as Tenant | undefined;
if (existing) {
db.prepare('UPDATE tenants SET last_accessed_at = ? WHERE id = ?').run(now, id);
return { ...existing, last_accessed_at: now };
}
db.prepare(
'INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)'
).run(id, name, workspacePath, now, now);
return { id, name, workspace_path: workspacePath, created_at: now, last_accessed_at: now };
}
export function setActiveTenant(id: string): void {
const tenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(id);
if (!tenant) throw new Error(`Tenant "${id}" not found`);
activeTenantId = id;
// Auto-set active project to the tenant's first project, creating a default if none exists
const firstProject = db.prepare(
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
).get(id) as { id: string } | undefined;
if (firstProject) {
activeProjectId = firstProject.id;
} else {
const defaultId = `${id}-default`;
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(defaultId, 'Default', 'Default project', id, now, now);
activeProjectId = defaultId;
}
logger.info(`Active tenant set to "${id}", active project: "${activeProjectId}"`);
}
export function getActiveTenant(): Tenant {
return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant;
}
export function getActiveTenantId(): string {
return activeTenantId;
}
export function listTenants(): Tenant[] {
return db.prepare('SELECT * FROM tenants ORDER BY last_accessed_at DESC').all() as Tenant[];
}
// ── Projects ──
export function createProject(name: string, description?: string): Project {
const id = generateId();
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, description || null, activeTenantId, now, now);
return { id, name, description: description || null, tenant_id: activeTenantId, created_at: now, updated_at: now };
}
export function listProjects(): Project[] {
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[];
}
export function setActiveProject(id: string): void {
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
if (!project) throw new Error(`Project "${id}" not found`);
if (project.tenant_id !== activeTenantId) {
throw new Error(`Project "${id}" belongs to tenant "${project.tenant_id}", not the active tenant "${activeTenantId}"`);
}
activeProjectId = id;
}
export function getActiveProject(): Project {
return db.prepare('SELECT * FROM projects WHERE id = ?').get(activeProjectId) as Project;
}
export function getActiveProjectId(): string {
return activeProjectId;
}
// ── Bulk operations (for sync endpoint) ──
export function bulkReplaceElements(elements: ServerElement[], projectId?: string): number {
const tx = db.transaction(() => {
clearElements(projectId);
for (const el of elements) {
setElement(el.id, el, projectId);
}
return elements.length;
});
return tx();
}
export function closeDb(): void {
if (db) {
db.close();
logger.info('SQLite database closed');
}
}
+329 -29
View File
@@ -6,7 +6,7 @@ process.env.NO_COLOR = '1';
import { fileURLToPath } from "url";
import { deflateSync } from 'zlib';
import { webcrypto } from 'crypto';
import { webcrypto, createHash } from 'crypto';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
@@ -28,6 +28,17 @@ import {
validateElement
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
import {
initDb, closeDb,
searchElements as dbSearchElements,
listProjects as dbListProjects, createProject as dbCreateProject,
setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject,
getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory,
ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant,
getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId,
listTenants as dbListTenants
} from './db.js';
// Load environment variables
dotenv.config();
@@ -47,9 +58,10 @@ function sanitizeFilePath(filePath: string): string {
return resolved;
}
// Express server configuration
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
// Express server configuration — derive URL from CANVAS_PORT
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;
// API Response types
interface ApiResponse {
@@ -66,6 +78,14 @@ interface SyncResponse {
elements?: ServerElement[];
}
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
return {
'Content-Type': 'application/json',
'X-Tenant-Id': dbGetActiveTenantId(),
...extra
};
}
// Helper functions to sync with Express server (canvas)
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
if (!ENABLE_CANVAS_SYNC) {
@@ -82,7 +102,7 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
url = `${EXPRESS_SERVER_URL}/api/elements`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify(data)
};
break;
@@ -91,21 +111,21 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify(data)
};
break;
case 'delete':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = { method: 'DELETE' };
options = { method: 'DELETE', headers: canvasHeaders() };
break;
case 'batch_create':
url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify({ elements: data })
};
break;
@@ -168,7 +188,9 @@ async function getElementFromCanvas(elementId: string): Promise<ServerElement |
}
try {
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`, {
headers: canvasHeaders()
});
if (!response.ok) {
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
return null;
@@ -819,6 +841,88 @@ const tools: Tool[] = [
}
}
}
},
{
name: 'search_elements',
description: 'Full-text search across element labels and text content. Returns elements matching the query.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query for FTS (matches against element labels and text)'
}
},
required: ['query']
}
},
{
name: 'list_projects',
description: 'List all diagram projects. Projects organize diagrams into separate workspaces.',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'switch_project',
description: 'Switch the active project or create a new one. All element operations apply to the active project.',
inputSchema: {
type: 'object',
properties: {
projectId: {
type: 'string',
description: 'ID of existing project to switch to'
},
createName: {
type: 'string',
description: 'Name for a new project (creates and switches to it)'
},
createDescription: {
type: 'string',
description: 'Optional description for the new project'
}
}
}
},
{
name: 'element_history',
description: 'View the version history of a specific element or the entire active project. Shows create, update, and delete operations.',
inputSchema: {
type: 'object',
properties: {
elementId: {
type: 'string',
description: 'Element ID to view history for (omit for project-wide history)'
},
limit: {
type: 'number',
description: 'Maximum number of history entries to return (default: 50)'
}
}
}
},
{
name: 'list_tenants',
description: 'List all tenants (workspaces). Each tenant corresponds to a Cursor workspace and has isolated diagrams.',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'switch_tenant',
description: 'Switch the active tenant (workspace). All subsequent operations will use the selected tenant\'s projects and elements.',
inputSchema: {
type: 'object',
properties: {
tenantId: {
type: 'string',
description: 'ID of the tenant to switch to'
}
},
required: ['tenantId']
}
}
];
@@ -983,9 +1087,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
});
}
// Query elements from HTTP server
const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
const response = await fetch(url);
const response = await fetch(url, { headers: canvasHeaders() });
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
@@ -1019,8 +1122,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'library':
case 'elements':
try {
// Get elements from HTTP server
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
@@ -1327,7 +1431,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
// The frontend will use mermaid-to-excalidraw to convert it
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify({
mermaidDiagram: params.mermaidDiagram,
config: params.config
@@ -1429,7 +1533,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
logger.info('Clearing canvas via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
method: 'DELETE'
method: 'DELETE',
headers: canvasHeaders()
});
if (!response.ok) {
@@ -1453,7 +1558,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
logger.info('Exporting scene via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`);
}
@@ -1522,9 +1629,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
throw new Error('No elements found in the import data');
}
// If replace mode, clear first
if (params.mode === 'replace') {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
}
// Batch create the imported elements
@@ -1557,7 +1663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify({
format: params.format,
background: params.background ?? true
@@ -1649,7 +1755,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify({ name: params.name })
});
@@ -1671,16 +1777,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const params = z.object({ name: z.string() }).parse(args);
logger.info('Restoring snapshot via MCP', { name: params.name });
// Fetch the snapshot
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`, {
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`Snapshot "${params.name}" not found`);
}
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
// Clear current canvas
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
// Restore elements
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
@@ -1696,7 +1802,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'describe_scene': {
logger.info('Describing scene via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`Failed to fetch elements: ${response.status}`);
}
@@ -1813,7 +1921,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify({
format: 'png',
background: params.background ?? true
@@ -1851,8 +1959,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'export_to_excalidraw_url': {
logger.info('Exporting to excalidraw.com URL');
// 1. Fetch current scene elements
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!urlExportResponse.ok) {
throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`);
}
@@ -2151,7 +2260,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: canvasHeaders(),
body: JSON.stringify(viewportParams)
});
@@ -2170,6 +2279,135 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
};
}
case 'search_elements': {
const params = z.object({ query: z.string() }).parse(args);
logger.info('Searching elements via MCP', { query: params.query });
const results = dbSearchElements(params.query);
return {
content: [{
type: 'text',
text: results.length > 0
? `Found ${results.length} matching elements:\n\n${JSON.stringify(results, null, 2)}`
: `No elements found matching "${params.query}"`
}]
};
}
case 'list_projects': {
logger.info('Listing projects via MCP');
const projects = dbListProjects();
const active = dbGetActiveProject();
return {
content: [{
type: 'text',
text: `Active project: ${active.name} (${active.id})\n\nAll projects:\n${JSON.stringify(projects, null, 2)}`
}]
};
}
case 'switch_project': {
const params = z.object({
projectId: z.string().optional(),
createName: z.string().optional(),
createDescription: z.string().optional()
}).parse(args || {});
if (params.createName) {
const newProject = dbCreateProject(params.createName, params.createDescription);
dbSetActiveProject(newProject.id);
logger.info('Created and switched to new project', { project: newProject });
return {
content: [{
type: 'text',
text: `Created new project "${newProject.name}" and switched to it.\n\n${JSON.stringify(newProject, null, 2)}`
}]
};
}
if (params.projectId) {
dbSetActiveProject(params.projectId);
const active = dbGetActiveProject();
logger.info('Switched project', { project: active });
return {
content: [{
type: 'text',
text: `Switched to project "${active.name}" (${active.id})`
}]
};
}
throw new Error('Provide either projectId to switch to or createName to create a new project');
}
case 'element_history': {
const params = z.object({
elementId: z.string().optional(),
limit: z.number().optional()
}).parse(args || {});
const limit = params.limit ?? 50;
if (params.elementId) {
const history = dbGetElementHistory(params.elementId, limit);
return {
content: [{
type: 'text',
text: history.length > 0
? `Version history for element ${params.elementId} (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
: `No history found for element ${params.elementId}`
}]
};
}
const history = dbGetProjectHistory(limit);
const active = dbGetActiveProject();
return {
content: [{
type: 'text',
text: history.length > 0
? `Project history for "${active.name}" (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
: `No history in project "${active.name}"`
}]
};
}
case 'list_tenants': {
logger.info('Listing tenants via MCP');
const tenants = dbListTenants();
const activeTenant = dbGetActiveTenant();
return {
content: [{
type: 'text',
text: `Active tenant: ${activeTenant.name} (${activeTenant.id})\nWorkspace: ${activeTenant.workspace_path}\n\nAll tenants:\n${JSON.stringify(tenants, null, 2)}`
}]
};
}
case 'switch_tenant': {
const params = z.object({ tenantId: z.string() }).parse(args);
logger.info('Switching tenant via MCP', { tenantId: params.tenantId });
dbSetActiveTenant(params.tenantId);
const tenant = dbGetActiveTenant();
const activeProject = dbGetActiveProject();
try {
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ tenantId: params.tenantId })
});
} catch {}
return {
content: [{
type: 'text',
text: `Switched to tenant "${tenant.name}" (${tenant.id})\nWorkspace: ${tenant.workspace_path}\nActive project: ${activeProject.name} (${activeProject.id})`
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
@@ -2193,12 +2431,74 @@ async function runServer(): Promise<void> {
try {
logger.info('Starting Excalidraw MCP server...');
// Initialize SQLite before anything else
initDb();
// Bootstrap tenant from process.cwd() (may be home dir for global MCPs)
let workspacePath = process.cwd();
function applyTenant(wp: string) {
const tid = createHash('sha256').update(wp).digest('hex').slice(0, 12);
const tname = path.basename(wp);
dbEnsureTenant(tid, tname, wp);
dbSetActiveTenant(tid);
logger.info(`Tenant initialized: "${tname}" (${tid}) from ${wp}`);
return { tenantId: tid, tenantName: tname };
}
applyTenant(workspacePath);
try {
await startCanvasServer();
logger.info('Canvas server started — lifecycle managed by MCP process');
} catch (canvasError) {
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
logger.warn('MCP tools will work without real-time canvas sync');
}
const transport = new StdioServerTransport();
logger.debug('Connecting to stdio transport...');
await server.connect(transport);
logger.info('Excalidraw MCP server running on stdio');
// After connecting, ask the client for the real workspace roots.
// Global MCPs often get cwd=HOME; roots gives us the actual workspace.
try {
const { roots } = await server.listRoots(undefined, { timeout: 5_000 });
if (roots && roots.length > 0) {
const rootUri = roots[0]!.uri;
const rootPath = rootUri.startsWith('file://') ? decodeURIComponent(rootUri.slice(7)) : rootUri;
if (rootPath && rootPath !== workspacePath) {
logger.info(`Client reported workspace root: ${rootPath} (was ${workspacePath})`);
workspacePath = rootPath;
const { tenantId: newTid } = applyTenant(workspacePath);
try {
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ tenantId: newTid })
});
} catch {}
}
}
} catch (rootsErr) {
logger.debug('Could not retrieve roots from client (not supported or timed out):', (rootsErr as Error).message);
}
async function shutdown() {
logger.info('MCP transport closed — shutting down');
try { await stopCanvasServer(); } catch {}
try { closeDb(); } catch {}
process.exit(0);
}
server.onclose = shutdown;
process.stdin.on('close', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.stdin.resume();
} catch (error) {
logger.error('Error starting server:', error);
+163 -94
View File
@@ -1,4 +1,4 @@
import express, { Request, Response, NextFunction } from 'express';
import express, { type Application, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
@@ -7,8 +7,6 @@ import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
elements,
snapshots,
generateId,
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
@@ -22,6 +20,8 @@ import {
InitialElementsMessage,
Snapshot
} from './types.js';
import * as store from './db.js';
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -31,9 +31,9 @@ dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
const app: Application = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
// Middleware
app.use(cors());
@@ -45,6 +45,14 @@ app.use(express.static(staticDir));
// Also serve frontend assets
app.use(express.static(path.join(__dirname, '../dist/frontend')));
// Resolve tenant from X-Tenant-Id header to a projectId override.
// Returns undefined when header is absent (browser requests), falling back to global state.
function resolveTenantProject(req: Request): string | undefined {
const tenantId = req.headers['x-tenant-id'] as string | undefined;
if (!tenantId) return undefined;
return getDefaultProjectForTenant(tenantId);
}
// WebSocket connections
const clients = new Set<WebSocket>();
@@ -62,18 +70,27 @@ function broadcast(message: WebSocketMessage): void {
wss.on('connection', (ws: WebSocket) => {
clients.add(ws);
logger.info('New WebSocket connection established');
// Send current tenant info
try {
const tenant = dbGetActiveTenant();
ws.send(JSON.stringify({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
}));
} catch {}
// Send current elements to new client
const initialMessage: InitialElementsMessage = {
type: 'initial_elements',
elements: Array.from(elements.values())
elements: store.getAllElements()
};
ws.send(JSON.stringify(initialMessage));
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
elementCount: elements.size,
elementCount: store.getElementCount(),
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(syncMessage));
@@ -161,11 +178,12 @@ const UpdateElementSchema = z.object({
// Get all elements
app.get('/api/elements', (req: Request, res: Response) => {
try {
const elementsArray = Array.from(elements.values());
const projId = resolveTenantProject(req);
const allElements = store.getAllElements(projId);
res.json({
success: true,
elements: elementsArray,
count: elementsArray.length
elements: allElements,
count: allElements.length
});
} catch (error) {
logger.error('Error fetching elements:', error);
@@ -179,10 +197,10 @@ app.get('/api/elements', (req: Request, res: Response) => {
// Create new element
app.post('/api/elements', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const params = CreateElementSchema.parse(req.body);
logger.info('Creating element via API', { type: params.type });
// Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId();
const element: ServerElement = {
id,
@@ -192,9 +210,8 @@ app.post('/api/elements', (req: Request, res: Response) => {
version: 1
};
elements.set(id, element);
store.setElement(id, element, projId);
// Broadcast to all connected clients
const message: ElementCreatedMessage = {
type: 'element_created',
element: element
@@ -217,6 +234,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
// Update element
app.put('/api/elements/:id', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { id } = req.params;
const updates = UpdateElementSchema.parse({ id, ...req.body });
@@ -227,7 +245,7 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
});
}
const existingElement = elements.get(id);
const existingElement = store.getElement(id, projId);
if (!existingElement) {
return res.status(404).json({
success: false,
@@ -242,9 +260,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
version: (existingElement.version || 0) + 1
};
elements.set(id, updatedElement);
store.setElement(id, updatedElement, projId);
// Broadcast to all connected clients
const message: ElementUpdatedMessage = {
type: 'element_updated',
element: updatedElement
@@ -267,8 +284,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
// Clear all elements (must be before /:id route)
app.delete('/api/elements/clear', (req: Request, res: Response) => {
try {
const count = elements.size;
elements.clear();
const projId = resolveTenantProject(req);
const count = store.clearElements(projId);
broadcast({
type: 'canvas_cleared',
@@ -294,6 +311,7 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
// Delete element
app.delete('/api/elements/:id', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { id } = req.params;
if (!id) {
@@ -303,14 +321,14 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
});
}
if (!elements.has(id)) {
if (!store.hasElement(id, projId)) {
return res.status(404).json({
success: false,
error: `Element with ID ${id} not found`
});
}
elements.delete(id);
store.deleteElement(id, projId);
// Broadcast to all connected clients
const message: ElementDeletedMessage = {
@@ -335,22 +353,19 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
// Query elements with filters
app.get('/api/elements/search', (req: Request, res: Response) => {
try {
const { type, ...filters } = req.query;
let results = Array.from(elements.values());
// Filter by type if specified
if (type && typeof type === 'string') {
results = results.filter(element => element.type === type);
}
// Apply additional filters
if (Object.keys(filters).length > 0) {
results = results.filter(element => {
return Object.entries(filters).every(([key, value]) => {
return (element as any)[key] === value;
});
});
const projId = resolveTenantProject(req);
const { type, q, ...filters } = req.query;
if (q && typeof q === 'string') {
const results = store.searchElements(q, projId);
return res.json({ success: true, elements: results, count: results.length });
}
const results = store.queryElements(
type && typeof type === 'string' ? type : undefined,
Object.keys(filters).length > 0 ? filters as Record<string, any> : undefined,
projId
);
res.json({
success: true,
@@ -369,6 +384,7 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
// Get element by ID
app.get('/api/elements/:id', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { id } = req.params;
if (!id) {
@@ -378,7 +394,7 @@ app.get('/api/elements/:id', (req: Request, res: Response) => {
});
}
const element = elements.get(id);
const element = store.getElement(id, projId);
if (!element) {
return res.status(404).json({
@@ -453,14 +469,14 @@ function computeEdgePoint(
}
// Helper: resolve arrow bindings in a batch
function resolveArrowBindings(batchElements: ServerElement[]): void {
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
const elementMap = new Map<string, ServerElement>();
batchElements.forEach(el => elementMap.set(el.id, el));
// Also check existing elements for cross-batch references
elements.forEach((el, id) => {
if (!elementMap.has(id)) elementMap.set(id, el);
});
for (const el of store.getAllElements(projectId)) {
if (!elementMap.has(el.id)) elementMap.set(el.id, el);
}
for (const el of batchElements) {
if (el.type !== 'arrow' && el.type !== 'line') continue;
@@ -535,6 +551,7 @@ function resolveArrowBindings(batchElements: ServerElement[]): void {
// Batch create elements
app.post('/api/elements/batch', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { elements: elementsToCreate } = req.body;
if (!Array.isArray(elementsToCreate)) {
@@ -548,7 +565,6 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData);
// Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId();
const element: ServerElement = {
id,
@@ -561,11 +577,9 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
createdElements.push(element);
});
// Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
resolveArrowBindings(createdElements);
resolveArrowBindings(createdElements, projId);
// Store all elements after binding resolution
createdElements.forEach(el => elements.set(el.id, el));
createdElements.forEach(el => store.setElement(el.id, el, projId));
// Broadcast to all connected clients
const message: BatchCreatedMessage = {
@@ -632,6 +646,7 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
// Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { elements: frontendElements, timestamp } = req.body;
logger.info(`Sync request received: ${frontendElements.length} elements`, {
@@ -639,7 +654,6 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
elementCount: frontendElements.length
});
// Validate input data
if (!Array.isArray(frontendElements)) {
return res.status(400).json({
success: false,
@@ -647,23 +661,15 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
});
}
// Record element count before sync
const beforeCount = elements.size;
// 1. Clear existing memory storage
elements.clear();
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
// 2. Batch write new data
let successCount = 0;
const beforeCount = store.getElementCount(projId);
// Process elements with server metadata
const processedElements: ServerElement[] = [];
let successCount = 0;
frontendElements.forEach((element: any, index: number) => {
try {
// Ensure element has ID, generate one if missing
const elementId = element.id || generateId();
// Add server metadata
const processedElement: ServerElement = {
...element,
id: elementId,
@@ -672,35 +678,30 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
syncTimestamp: timestamp,
version: 1
};
// Store to memory
elements.set(elementId, processedElement);
processedElements.push(processedElement);
successCount++;
} catch (elementError) {
logger.warn(`Failed to process element ${index}:`, elementError);
}
});
store.bulkReplaceElements(processedElements, projId);
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
// 3. Broadcast sync event to all WebSocket clients
broadcast({
type: 'elements_synced',
count: successCount,
timestamp: new Date().toISOString(),
source: 'manual_sync'
});
// 4. Return sync results
res.json({
success: true,
message: `Successfully synced ${successCount} elements`,
count: successCount,
syncedAt: new Date().toISOString(),
beforeCount,
afterCount: elements.size
afterCount: store.getElementCount(projId)
});
} catch (error) {
@@ -919,6 +920,7 @@ app.post('/api/viewport/result', (req: Request, res: Response) => {
// Snapshots: save
app.post('/api/snapshots', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { name } = req.body;
if (!name || typeof name !== 'string') {
@@ -928,20 +930,15 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
});
}
const snapshot: Snapshot = {
name,
elements: Array.from(elements.values()),
createdAt: new Date().toISOString()
};
snapshots.set(name, snapshot);
logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
const allElements = store.getAllElements(projId);
store.saveSnapshot(name, allElements, projId);
logger.info(`Snapshot saved: "${name}" with ${allElements.length} elements`);
res.json({
success: true,
name,
elementCount: snapshot.elements.length,
createdAt: snapshot.createdAt
elementCount: allElements.length,
createdAt: new Date().toISOString()
});
} catch (error) {
logger.error('Error saving snapshot:', error);
@@ -955,11 +952,8 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
// Snapshots: list
app.get('/api/snapshots', (req: Request, res: Response) => {
try {
const list = Array.from(snapshots.values()).map(s => ({
name: s.name,
elementCount: s.elements.length,
createdAt: s.createdAt
}));
const projId = resolveTenantProject(req);
const list = store.listSnapshots(projId);
res.json({
success: true,
@@ -978,8 +972,9 @@ app.get('/api/snapshots', (req: Request, res: Response) => {
// Snapshots: get by name
app.get('/api/snapshots/:name', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { name } = req.params;
const snapshot = snapshots.get(name!);
const snapshot = store.getSnapshot(name!, projId);
if (!snapshot) {
return res.status(404).json({
@@ -1012,21 +1007,68 @@ app.get('/', (req: Request, res: Response) => {
});
});
// ── Tenant API ──
app.get('/api/tenants', (req: Request, res: Response) => {
try {
const tenants = dbListTenants();
const active = dbGetActiveTenant();
res.json({ success: true, tenants, activeTenantId: active.id });
} catch (error) {
logger.error('Error listing tenants:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.get('/api/tenant/active', (req: Request, res: Response) => {
try {
const tenant = dbGetActiveTenant();
res.json({ success: true, tenant });
} catch (error) {
logger.error('Error getting active tenant:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.put('/api/tenant/active', (req: Request, res: Response) => {
try {
const { tenantId } = req.body;
if (!tenantId || typeof tenantId !== 'string') {
return res.status(400).json({ success: false, error: 'tenantId is required' });
}
dbSetActiveTenant(tenantId);
const tenant = dbGetActiveTenant();
broadcast({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
});
res.json({ success: true, tenant });
} catch (error) {
logger.error('Error switching tenant:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
elements_count: elements.size,
elements_count: store.getElementCount(projId),
websocket_clients: clients.size
});
});
// Sync status endpoint
app.get('/api/sync/status', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
res.json({
success: true,
elementCount: elements.size,
elementCount: store.getElementCount(projId),
timestamp: new Date().toISOString(),
memoryUsage: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
@@ -1045,13 +1087,40 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
});
});
// Start server
const PORT = parseInt(process.env.PORT || '3000', 10);
// Server configuration
const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10);
const HOST = process.env.HOST || 'localhost';
server.listen(PORT, HOST, () => {
logger.info(`POC server running on http://${HOST}:${PORT}`);
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
});
export function startCanvasServer(): Promise<void> {
return new Promise((resolve, reject) => {
const onError = (err: Error) => {
httpServer.removeListener('error', onError);
reject(err);
};
httpServer.on('error', onError);
httpServer.listen(PORT, HOST, () => {
httpServer.removeListener('error', onError);
logger.info(`Canvas server running on http://${HOST}:${PORT}`);
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
resolve();
});
});
}
export function stopCanvasServer(): Promise<void> {
return new Promise((resolve) => {
clients.forEach(c => c.close());
httpServer.close(() => resolve());
});
}
// Direct execution: `node dist/server.js` still works standalone
if (fileURLToPath(import.meta.url) === process.argv[1]) {
startCanvasServer().catch((err) => {
logger.error('Failed to start canvas server:', err);
process.exit(1);
});
}
export default app;
+15 -6
View File
@@ -182,7 +182,8 @@ export type WebSocketMessageType =
| 'mermaid_convert'
| 'canvas_cleared'
| 'export_image_request'
| 'set_viewport';
| 'set_viewport'
| 'tenant_switched';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
@@ -271,6 +272,16 @@ export interface SetViewportMessage extends WebSocketMessage {
offsetY?: number;
}
// Tenant switched message
export interface TenantSwitchedMessage extends WebSocketMessage {
type: 'tenant_switched';
tenant: {
id: string;
name: string;
workspace_path: string;
};
}
// Snapshot types
export interface Snapshot {
name: string;
@@ -278,11 +289,9 @@ export interface Snapshot {
createdAt: string;
}
// In-memory storage for Excalidraw elements
export const elements = new Map<string, ServerElement>();
// In-memory storage for snapshots
export const snapshots = new Map<string, Snapshot>();
// 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.
// Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): element is ServerElement {