From 4c50472ee49f142d09154abdf563830612d85036 Mon Sep 17 00:00:00 2001 From: sanjibdevnathlabs Date: Fri, 13 Mar 2026 08:53:20 +0530 Subject: [PATCH] Startup fixes --- CLAUDE.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +++- src/server.ts | 39 ++++++++++++++++--- 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a6a72d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +A fully local, self-hosted Excalidraw MCP server. Single Node.js process that runs an MCP server (stdio, 32 tools), an embedded Express+WebSocket canvas server, and SQLite persistence with multi-tenancy. Forked from [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw). + +## Build & Development Commands + +```bash +# Install dependencies (pnpm preferred, npm works too) +pnpm install +pnpm rebuild better-sqlite3 esbuild + +# Full build (frontend + server) +pnpm run build + +# Build only server (TypeScript) +pnpm run build:server # npx tsc + +# Build only frontend (Vite/React) +pnpm run build:frontend # vite build + +# Type check without emit +pnpm run type-check # npx tsc --noEmit + +# Dev mode (watch server + Vite dev server on :5173) +pnpm run dev + +# Run the MCP server (starts MCP stdio + canvas on :3000) +node dist/index.js + +# Run canvas server standalone +node dist/server.js + +# Health check +curl http://localhost:3000/health +``` + +There are no unit tests. Validation is done via type checking (`pnpm run type-check`) and build verification. The CI runs `type-check` then `build` across Node 18/20/22. + +## Architecture + +**Single process, three subsystems:** + +``` +src/index.ts ── MCP Server (stdio) ── 32 tools, connects to canvas via HTTP + ├── imports server.ts ── Canvas Server (Express + WebSocket on CANVAS_PORT) + ├── imports db.ts ── SQLite layer (better-sqlite3, WAL mode) + └── imports types.ts ── Shared types, element validation, ID generation + +frontend/ ── React + Excalidraw UI (Vite build → dist/frontend/) + ├── src/App.tsx ── Main component, WS connection, auto-sync, workspace switcher + └── src/main.tsx ── Entry point +``` + +**Data flow:** MCP tool call → `index.ts` handler → HTTP to canvas REST API (`server.ts`) → SQLite (`db.ts`) + WebSocket broadcast → frontend updates. + +**Key design decisions:** +- Canvas server is embedded in the MCP process — `startCanvasServer()` is called from `runServer()`. If port is taken by an existing healthy instance, it reuses it instead of crashing. +- Multi-tenancy: workspace path → SHA-256 hash (12 chars) → tenant ID. Each tenant has isolated projects/elements. Tenant auto-detected via `server.listRoots()` after MCP connection. +- All element data stored as JSON blobs in SQLite `elements.data` column. FTS5 virtual table for full-text search on labels. +- Logging goes to file (`excalidraw.log`) at debug level, only warn+error to stderr (to avoid breaking stdio JSON protocol). + +## Source Files + +| File | Purpose | +|------|---------| +| `src/index.ts` (~2540 lines) | MCP server entry point. Tool definitions, tool handlers, tenant bootstrap, server lifecycle. | +| `src/server.ts` (~1155 lines) | Express canvas server. REST API, WebSocket, Zod schemas, arrow binding resolution, image export relay. | +| `src/db.ts` (~510 lines) | SQLite persistence. Migrations, CRUD, FTS, versioning, snapshots, tenants, projects. | +| `src/types.ts` (~315 lines) | TypeScript interfaces for elements, WebSocket messages, API responses. `generateId()` and `validateElement()`. | +| `src/utils/logger.ts` | Winston logger config (file + stderr). | +| `frontend/src/App.tsx` | React Excalidraw wrapper with WS sync, auto-sync, workspace switcher. | +| `vite.config.js` | Frontend build config. Root=`frontend/`, output=`dist/frontend/`. Dev proxy to `:3000`. | + +## Environment Variables + +| Variable | Default | Notes | +|----------|---------|-------| +| `CANVAS_PORT` | `3000` | Canvas server port | +| `EXCALIDRAW_DB_PATH` | `~/.excalidraw-mcp/excalidraw.db` | SQLite database location | +| `EXCALIDRAW_EXPORT_DIR` | `process.cwd()` | Allowed directory for file exports (path traversal protection) | +| `EXPRESS_SERVER_URL` | `http://localhost:{CANVAS_PORT}` | Only needed if running canvas separately | +| `LOG_FILE_PATH` | `excalidraw.log` | Winston log file | +| `LOG_LEVEL` | `info` | Winston log level | + +## TypeScript Configuration + +- ESM modules (`"type": "module"` in package.json, `"module": "ESNext"` in tsconfig) +- Strict mode enabled with `noUncheckedIndexedAccess` +- Target ES2022, output to `dist/` +- All `.js` imports in source use `.js` extension (ESM requirement) + +## Key Patterns + +- **Canvas sync is fire-and-forget**: MCP tool handlers call canvas REST API but don't fail if canvas is unavailable. The `syncToCanvas()` helper catches errors and returns null. +- **Tenant-scoped operations**: Every REST endpoint resolves tenant via `X-Tenant-Id` header → `resolveTenantProject()` → project ID. Browser requests (no header) fall back to global active state. +- **Arrow binding**: `startElementId`/`endElementId` on arrows are resolved to edge-point coordinates in `resolveArrowBindings()` (server.ts). The server computes intersection points for rectangle/ellipse/diamond shapes. +- **Image export relay**: MCP → REST `/api/export/image` → WebSocket broadcast → frontend renders → POST back to `/api/export/image/result` → resolves pending promise. +- **Element versioning**: Every create/update/delete records a version in `element_versions` table. Soft-delete pattern (`is_deleted` flag). + +## Docker + +Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas with frontend). `docker-compose.yml` orchestrates both with a `full` profile. diff --git a/src/index.ts b/src/index.ts index da6fb23..7fc41c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2508,7 +2508,13 @@ async function runServer(): Promise { } // Add global error handlers -process.on('uncaughtException', (error: Error) => { +process.on('uncaughtException', (error: Error & { code?: string }) => { + // EADDRINUSE from the canvas server is handled gracefully in startCanvasServer — + // don't let a stray emit from httpServer kill the entire MCP process. + if (error.code === 'EADDRINUSE') { + logger.warn('Ignoring EADDRINUSE in global handler (canvas server will reuse existing instance)'); + return; + } logger.error('Uncaught exception:', error); process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`); setTimeout(() => process.exit(1), 1000); diff --git a/src/server.ts b/src/server.ts index 3cd3098..40b0d6f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1091,9 +1091,33 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => { const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10); const HOST = process.env.HOST || 'localhost'; -export function startCanvasServer(): Promise { - return new Promise((resolve, reject) => { - const onError = (err: Error) => { +/** Track whether we own the canvas server or are reusing an existing one. */ +let canvasServerOwned = false; + +export function isCanvasServerOwned(): boolean { + return canvasServerOwned; +} + +export async function startCanvasServer(): Promise { + // 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. + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(`http://${HOST}:${PORT}/health`, { signal: controller.signal as any }); + clearTimeout(timeout); + const body = await res.json() as any; + if (body?.status === 'healthy') { + logger.info(`Reusing existing canvas server on port ${PORT} (elements: ${body.elements_count}, ws clients: ${body.websocket_clients})`); + return; // reuse — skip listen entirely + } + } catch { + // No server on this port (connection refused) or not a canvas server — proceed to start our own + } + + await new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException) => { httpServer.removeListener('error', onError); reject(err); }; @@ -1101,14 +1125,19 @@ export function startCanvasServer(): Promise { 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(); }); }); + canvasServerOwned = true; + logger.info(`Canvas server running on http://${HOST}:${PORT}`); + logger.info(`WebSocket server running on ws://${HOST}:${PORT}`); } export function stopCanvasServer(): Promise { + if (!canvasServerOwned) { + logger.info('Canvas server not owned by this process, skipping shutdown'); + return Promise.resolve(); + } return new Promise((resolve) => { clients.forEach(c => c.close()); httpServer.close(() => resolve());