Startup fixes
This commit is contained in:
@@ -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.
|
||||
+7
-1
@@ -2508,7 +2508,13 @@ async function runServer(): Promise<void> {
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
+34
-5
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
httpServer.removeListener('error', onError);
|
||||
reject(err);
|
||||
};
|
||||
@@ -1101,14 +1125,19 @@ export function startCanvasServer(): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user