vibe-annotations-server 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.js ADDED
@@ -0,0 +1,1091 @@
1
+ #!/usr/bin/env node
2
+
3
+ import express from 'express';
4
+ import cors from 'cors';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
8
+ import {
9
+ CallToolRequestSchema,
10
+ ListToolsRequestSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ import { readFile, writeFile, mkdir } from 'fs/promises';
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { randomUUID } from 'crypto';
17
+ import chalk from 'chalk';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = path.dirname(__filename);
21
+
22
+ // Read version from package.json automatically
23
+ const packageJson = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
24
+
25
+ // Configuration
26
+ const PORT = 3846;
27
+ const DATA_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.vibe-annotations');
28
+ const DATA_FILE = path.join(DATA_DIR, 'annotations.json');
29
+
30
+ class LocalAnnotationsServer {
31
+ constructor() {
32
+ this.app = express();
33
+ this.mcpServer = new Server(
34
+ {
35
+ name: 'claude-annotations',
36
+ version: '0.1.0',
37
+ },
38
+ {
39
+ capabilities: {
40
+ tools: {},
41
+ },
42
+ }
43
+ );
44
+ this.isShuttingDown = false;
45
+ this.handlersSetup = false;
46
+ this.transports = {}; // Track transport sessions
47
+ this.connections = new Set(); // Track HTTP connections
48
+ this.saveLock = Promise.resolve(); // Serialize save operations to prevent race conditions
49
+
50
+ this.setupExpress();
51
+ this.setupMCP();
52
+ }
53
+
54
+ setupExpress() {
55
+ this.app.use(cors({
56
+ origin: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:5173', 'http://localhost:8080', 'http://127.0.0.1:3000'],
57
+ credentials: true
58
+ }));
59
+ this.app.use(express.json());
60
+
61
+ // Health check with version info
62
+ this.app.get('/health', (req, res) => {
63
+ res.json({
64
+ status: 'ok',
65
+ version: packageJson.version,
66
+ minExtensionVersion: '1.0.0', // Minimum compatible extension version
67
+ timestamp: new Date().toISOString()
68
+ });
69
+ });
70
+
71
+ // API endpoints for Chrome extension
72
+ this.app.get('/api/annotations', async (req, res) => {
73
+ try {
74
+ const annotations = await this.loadAnnotations();
75
+ const { status, url, limit = 50 } = req.query;
76
+
77
+ let filtered = annotations;
78
+
79
+ if (status && status !== 'all') {
80
+ filtered = filtered.filter(a => a.status === status);
81
+ }
82
+
83
+ if (url) {
84
+ filtered = filtered.filter(a => a.url === url);
85
+ }
86
+
87
+ filtered = filtered.slice(0, parseInt(limit));
88
+
89
+ res.json({
90
+ annotations: filtered,
91
+ count: filtered.length,
92
+ total: annotations.length
93
+ });
94
+ } catch (error) {
95
+ console.error('Error loading annotations:', error);
96
+ res.status(500).json({ error: 'Failed to load annotations' });
97
+ }
98
+ });
99
+
100
+ this.app.post('/api/annotations', async (req, res) => {
101
+ try {
102
+ const annotation = req.body;
103
+
104
+ // Validate annotation
105
+ if (!annotation.id || !annotation.url || !annotation.comment) {
106
+ return res.status(400).json({ error: 'Missing required fields' });
107
+ }
108
+
109
+ const annotations = await this.loadAnnotations();
110
+ const existingIndex = annotations.findIndex(a => a.id === annotation.id);
111
+
112
+ if (existingIndex >= 0) {
113
+ annotations[existingIndex] = { ...annotations[existingIndex], ...annotation, updated_at: new Date().toISOString() };
114
+ } else {
115
+ annotations.push({
116
+ ...annotation,
117
+ created_at: annotation.created_at || new Date().toISOString(),
118
+ updated_at: new Date().toISOString()
119
+ });
120
+ }
121
+
122
+ await this.saveAnnotations(annotations);
123
+ res.json({ success: true, annotation });
124
+ } catch (error) {
125
+ console.error('Error saving annotation:', error);
126
+ res.status(500).json({ error: 'Failed to save annotation' });
127
+ }
128
+ });
129
+
130
+ // New endpoint to sync all annotations (replace existing)
131
+ this.app.post('/api/annotations/sync', async (req, res) => {
132
+ try {
133
+ const { annotations } = req.body;
134
+
135
+ if (!Array.isArray(annotations)) {
136
+ return res.status(400).json({ error: 'annotations must be an array' });
137
+ }
138
+
139
+ // Get current annotations for comparison
140
+ const currentAnnotations = await this.loadAnnotations();
141
+ console.log(`Sync request: replacing ${currentAnnotations.length} annotations with ${annotations.length} annotations`);
142
+
143
+ // Check if data is actually different to avoid redundant saves
144
+ const currentJson = JSON.stringify(currentAnnotations.sort((a, b) => a.id.localeCompare(b.id)));
145
+ const newJson = JSON.stringify(annotations.sort((a, b) => a.id.localeCompare(b.id)));
146
+
147
+ if (currentJson === newJson) {
148
+ console.log(`Sync skipped: data is identical`);
149
+ res.json({ success: true, count: annotations.length, skipped: true });
150
+ return;
151
+ }
152
+
153
+ // Replace all annotations with the new set
154
+ await this.saveAnnotations(annotations);
155
+ console.log(`Sync completed: now have ${annotations.length} annotations`);
156
+ res.json({ success: true, count: annotations.length });
157
+ } catch (error) {
158
+ console.error('Error syncing annotations:', error);
159
+ res.status(500).json({ error: 'Failed to sync annotations' });
160
+ }
161
+ });
162
+
163
+ this.app.put('/api/annotations/:id', async (req, res) => {
164
+ try {
165
+ const { id } = req.params;
166
+ const updates = req.body;
167
+
168
+ const annotations = await this.loadAnnotations();
169
+ const index = annotations.findIndex(a => a.id === id);
170
+
171
+ if (index === -1) {
172
+ return res.status(404).json({ error: 'Annotation not found' });
173
+ }
174
+
175
+ annotations[index] = {
176
+ ...annotations[index],
177
+ ...updates,
178
+ updated_at: new Date().toISOString()
179
+ };
180
+
181
+ await this.saveAnnotations(annotations);
182
+ res.json({ success: true, annotation: annotations[index] });
183
+ } catch (error) {
184
+ console.error('Error updating annotation:', error);
185
+ res.status(500).json({ error: 'Failed to update annotation' });
186
+ }
187
+ });
188
+
189
+ this.app.delete('/api/annotations/:id', async (req, res) => {
190
+ try {
191
+ const { id } = req.params;
192
+
193
+ const annotations = await this.loadAnnotations();
194
+ const index = annotations.findIndex(a => a.id === id);
195
+
196
+ if (index === -1) {
197
+ return res.status(404).json({ error: 'Annotation not found' });
198
+ }
199
+
200
+ const deletedAnnotation = annotations[index];
201
+ annotations.splice(index, 1);
202
+
203
+ await this.saveAnnotations(annotations);
204
+ res.json({
205
+ success: true,
206
+ deleted: true,
207
+ message: `Annotation ${id} has been successfully deleted`,
208
+ deletedAnnotation
209
+ });
210
+ } catch (error) {
211
+ console.error('Error deleting annotation:', error);
212
+ res.status(500).json({ error: 'Failed to delete annotation' });
213
+ }
214
+ });
215
+
216
+ // SSE endpoint for MCP connection (proper MCP SSE transport)
217
+ this.app.get('/sse', async (req, res) => {
218
+ console.log('Received GET request to /sse (MCP SSE transport)');
219
+
220
+ try {
221
+ const transport = new SSEServerTransport('/messages', res);
222
+ this.transports[transport.sessionId] = transport;
223
+
224
+ // Clean up transport on connection close
225
+ res.on("close", () => {
226
+ console.log(`SSE connection closed for session ${transport.sessionId}`);
227
+ try {
228
+ if (transport && typeof transport.close === 'function') {
229
+ transport.close();
230
+ }
231
+ } catch (error) {
232
+ console.warn(`Error closing transport ${transport.sessionId}:`, error.message);
233
+ }
234
+ delete this.transports[transport.sessionId];
235
+ });
236
+
237
+ // Handle connection errors
238
+ res.on("error", (error) => {
239
+ console.warn(`SSE connection error for session ${transport.sessionId}:`, error.message);
240
+ try {
241
+ if (transport && typeof transport.close === 'function') {
242
+ transport.close();
243
+ }
244
+ } catch (closeError) {
245
+ console.warn(`Error closing transport ${transport.sessionId}:`, closeError.message);
246
+ }
247
+ delete this.transports[transport.sessionId];
248
+ });
249
+
250
+ // Create fresh server and connect to transport
251
+ const server = this.createMCPServer();
252
+ await server.connect(transport);
253
+
254
+ console.log(`SSE transport connected with session ID: ${transport.sessionId}`);
255
+ } catch (error) {
256
+ console.error('Error setting up SSE transport:', error);
257
+ if (!res.headersSent) {
258
+ res.status(500).json({ error: 'Failed to establish SSE connection' });
259
+ }
260
+ }
261
+ });
262
+
263
+ // Messages endpoint for SSE transport (handles incoming MCP messages)
264
+ this.app.post('/messages', async (req, res) => {
265
+ console.log('Received POST request to /messages');
266
+
267
+ try {
268
+ const sessionId = req.query.sessionId;
269
+ const transport = this.transports[sessionId];
270
+
271
+ if (!transport || !(transport instanceof SSEServerTransport)) {
272
+ console.error(`No SSE transport found for session ID: ${sessionId}`);
273
+ res.status(400).json({
274
+ jsonrpc: '2.0',
275
+ error: {
276
+ code: -32000,
277
+ message: 'Bad Request: No valid SSE transport found for session ID',
278
+ },
279
+ id: null,
280
+ });
281
+ return;
282
+ }
283
+
284
+ // Handle the message using the transport
285
+ await transport.handlePostMessage(req, res, req.body);
286
+ console.log(`Message handled for session ${sessionId}`);
287
+ } catch (error) {
288
+ console.error('Error handling message:', error);
289
+ if (!res.headersSent) {
290
+ res.status(500).json({
291
+ jsonrpc: '2.0',
292
+ error: {
293
+ code: -32603,
294
+ message: 'Internal server error',
295
+ },
296
+ id: null,
297
+ });
298
+ }
299
+ }
300
+ });
301
+
302
+ // MCP HTTP endpoint - create fresh instances per request
303
+ this.app.use('/mcp', async (req, res) => {
304
+ try {
305
+ // Create fresh server and transport for each request to avoid "already initialized" error
306
+ const server = this.createMCPServer();
307
+
308
+ const transport = new StreamableHTTPServerTransport({
309
+ sessionIdGenerator: undefined, // Stateless mode
310
+ allowedOrigins: ['*'], // Allow all origins for MCP
311
+ enableDnsRebindingProtection: false // Disable for localhost
312
+ });
313
+
314
+ // Connect server to transport and handle request
315
+ await server.connect(transport);
316
+ await transport.handleRequest(req, res, req.body);
317
+ } catch (error) {
318
+ console.error('MCP connection error:', error);
319
+ if (!res.headersSent) {
320
+ res.status(500).json({ error: 'MCP connection failed' });
321
+ }
322
+ }
323
+ });
324
+ }
325
+
326
+ setupMCP() {
327
+ // Original server setup - now unused
328
+ }
329
+
330
+ // Helper method to create fresh MCP server instances
331
+ createMCPServer() {
332
+ const server = new Server(
333
+ {
334
+ name: 'claude-annotations',
335
+ version: '0.1.0',
336
+ },
337
+ {
338
+ capabilities: {
339
+ tools: {},
340
+ },
341
+ }
342
+ );
343
+
344
+ // Set up handlers for this instance
345
+ this.setupMCPHandlersForServer(server);
346
+
347
+ return server;
348
+ }
349
+
350
+ setupMCPHandlersForServer(server) {
351
+ // List tools
352
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
353
+ return {
354
+ tools: [
355
+ {
356
+ name: 'read_annotations',
357
+ description: 'Retrieves user-created visual annotations from the Vibe Annotations extension with enhanced context including element screenshots and parent hierarchy. Use when users want to review, implement, or address their UI feedback and comments. MULTI-PROJECT SAFETY: This tool now detects when annotations exist across multiple localhost projects and provides warnings with specific URL filtering guidance. CRITICAL WORKFLOW: (1) First call WITHOUT url parameter to see all projects, (2) Use get_project_context tool to determine current project, (3) Call again WITH url parameter (e.g., "http://localhost:3000/*") to filter for current project only. This prevents cross-project contamination where you might implement changes in wrong codebase. Returns enhanced warnings when multiple projects detected, with suggested URL filters for each project. Annotations include viewport dimensions for responsive breakpoint mapping. Use this tool when users mention: annotations, comments, feedback, suggestions, notes, marked changes, or visual issues they\'ve identified.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ status: {
362
+ type: 'string',
363
+ enum: ['pending', 'completed', 'archived', 'all'],
364
+ default: 'pending',
365
+ description: 'Filter annotations by status'
366
+ },
367
+ limit: {
368
+ type: 'number',
369
+ default: 50,
370
+ minimum: 1,
371
+ maximum: 200,
372
+ description: 'Maximum number of annotations to return'
373
+ },
374
+ url: {
375
+ type: 'string',
376
+ description: 'Filter by specific localhost URL. Supports exact match (e.g., "http://localhost:3000/dashboard") or pattern match with base URL (e.g., "http://localhost:3000/" or "http://localhost:3000/*" to get all annotations from that project)'
377
+ }
378
+ },
379
+ additionalProperties: false
380
+ }
381
+ },
382
+ {
383
+ name: 'delete_annotation',
384
+ description: 'Permanently removes a specific annotation after successfully implementing the requested change or fix. IMPORTANT: Consider using delete_project_annotations for batch deletion when implementing multiple fixes. Use this individual deletion tool when: (1) You have successfully implemented a single annotation fix, (2) You prefer to delete annotations one-by-one as you implement them, (3) You are working on just one annotation. For efficiency when handling multiple annotations, use delete_project_annotations instead. The deletion is irreversible and removes the annotation from both extension storage and MCP data. NEVER delete annotations that still need work, contain unaddressed feedback, or serve as ongoing reminders.',
385
+ inputSchema: {
386
+ type: 'object',
387
+ properties: {
388
+ id: {
389
+ type: 'string',
390
+ description: 'Annotation ID to delete'
391
+ }
392
+ },
393
+ required: ['id'],
394
+ additionalProperties: false
395
+ }
396
+ },
397
+ {
398
+ name: 'get_project_context',
399
+ description: 'Analyzes a localhost development URL to infer project framework and technology stack context. This tool helps understand the development environment when implementing annotation fixes by identifying likely frameworks (React, Vue, Angular, etc.) based on common port conventions. Use this tool when you need to understand what type of project you\'re working with before making code changes or when annotations reference framework-specific concerns. The tool maps common development server ports to their typical frameworks: port 3000 suggests React/Next.js, 5173 indicates Vite, 8080 points to Vue/Webpack, 4200 suggests Angular, and 3001 typically indicates Express/Node.js. This context helps you choose appropriate implementation approaches and understand the likely project structure. ENHANCED: Now includes working directory detection, package.json analysis, and recommended URL filtering patterns for multi-project environments.',
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {
403
+ url: {
404
+ type: 'string',
405
+ description: 'Complete localhost development URL (e.g., "http://localhost:3000/dashboard") to analyze for project context and framework inference'
406
+ }
407
+ },
408
+ required: ['url'],
409
+ additionalProperties: false
410
+ }
411
+ },
412
+ {
413
+ name: 'delete_project_annotations',
414
+ description: 'Batch delete ALL annotations for a specific project after successfully implementing all requested changes. CRITICAL WORKFLOW: Use this tool instead of individual delete_annotation calls when you have completed ALL annotation fixes for a project. This implements the efficient "read all → implement all → delete all" workflow. SAFETY: Requires URL pattern (like "http://localhost:3000/*") to prevent accidental deletion across projects. Always confirm the count of annotations to be deleted before proceeding. Use this tool when: (1) You have successfully implemented ALL annotation fixes for a project, (2) All code changes are complete and working, (3) You want to clean up all annotations for the project at once. This is more efficient than deleting annotations one-by-one.',
415
+ inputSchema: {
416
+ type: 'object',
417
+ properties: {
418
+ url_pattern: {
419
+ type: 'string',
420
+ description: 'URL pattern to match annotations for deletion (e.g., "http://localhost:3000/*" or "http://localhost:3000/" for all annotations from that project)'
421
+ },
422
+ confirm: {
423
+ type: 'boolean',
424
+ default: false,
425
+ description: 'Set to true to confirm batch deletion. First call without confirm=true to see how many annotations would be deleted.'
426
+ }
427
+ },
428
+ required: ['url_pattern'],
429
+ additionalProperties: false
430
+ }
431
+ }
432
+ ]
433
+ };
434
+ });
435
+
436
+ // Handle tool calls
437
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
438
+ const { name, arguments: args } = request.params;
439
+
440
+ try {
441
+ switch (name) {
442
+ case 'read_annotations': {
443
+ const result = await this.readAnnotations(args || {});
444
+ const { annotations, projectInfo, multiProjectWarning } = result;
445
+
446
+ return {
447
+ content: [
448
+ {
449
+ type: 'text',
450
+ text: JSON.stringify({
451
+ tool: 'read_annotations',
452
+ status: 'success',
453
+ data: annotations,
454
+ count: annotations.length,
455
+ projects: projectInfo,
456
+ multi_project_warning: multiProjectWarning,
457
+ filter_applied: args?.url || 'none',
458
+ timestamp: new Date().toISOString()
459
+ }, null, 2)
460
+ }
461
+ ]
462
+ };
463
+ }
464
+
465
+ case 'delete_annotation': {
466
+ const result = await this.deleteAnnotation(args);
467
+ return {
468
+ content: [
469
+ {
470
+ type: 'text',
471
+ text: JSON.stringify({
472
+ tool: 'delete_annotation',
473
+ status: 'success',
474
+ data: result,
475
+ timestamp: new Date().toISOString()
476
+ }, null, 2)
477
+ }
478
+ ]
479
+ };
480
+ }
481
+
482
+ case 'get_project_context': {
483
+ const context = await this.getProjectContext(args);
484
+ return {
485
+ content: [
486
+ {
487
+ type: 'text',
488
+ text: JSON.stringify({
489
+ tool: 'get_project_context',
490
+ status: 'success',
491
+ data: context,
492
+ timestamp: new Date().toISOString()
493
+ }, null, 2)
494
+ }
495
+ ]
496
+ };
497
+ }
498
+
499
+ case 'delete_project_annotations': {
500
+ const result = await this.deleteProjectAnnotations(args);
501
+ return {
502
+ content: [
503
+ {
504
+ type: 'text',
505
+ text: JSON.stringify({
506
+ tool: 'delete_project_annotations',
507
+ status: 'success',
508
+ data: result,
509
+ timestamp: new Date().toISOString()
510
+ }, null, 2)
511
+ }
512
+ ]
513
+ };
514
+ }
515
+
516
+ default:
517
+ throw new Error(`Unknown tool: ${name}`);
518
+ }
519
+ } catch (error) {
520
+ throw new Error(`Tool execution failed: ${error.message}`);
521
+ }
522
+ });
523
+
524
+ server.onerror = (error) => {
525
+ console.error('[MCP Error]', error);
526
+ };
527
+ }
528
+
529
+ async loadAnnotations() {
530
+ try {
531
+ if (!existsSync(DATA_FILE)) {
532
+ await this.ensureDataFile();
533
+ return [];
534
+ }
535
+ const data = await readFile(DATA_FILE, 'utf8');
536
+
537
+ // Handle empty or corrupted file
538
+ if (!data || data.trim() === '') {
539
+ console.warn('Empty annotations file, initializing with empty array');
540
+ await this.saveAnnotations([]);
541
+ return [];
542
+ }
543
+
544
+ try {
545
+ return JSON.parse(data);
546
+ } catch (parseError) {
547
+ console.error('Corrupted JSON file, reinitializing:', parseError);
548
+ // Backup corrupted file
549
+ const backupFile = DATA_FILE + '.corrupted.' + Date.now();
550
+ await writeFile(backupFile, data);
551
+ console.log(`Corrupted file backed up to: ${backupFile}`);
552
+
553
+ // Reinitialize with empty array
554
+ await this.saveAnnotations([]);
555
+ return [];
556
+ }
557
+ } catch (error) {
558
+ console.error('Error loading annotations:', error);
559
+ return [];
560
+ }
561
+ }
562
+
563
+ async saveAnnotations(annotations) {
564
+ // Serialize all save operations to prevent race conditions
565
+ this.saveLock = this.saveLock.then(async () => {
566
+ return this._saveAnnotationsInternal(annotations);
567
+ });
568
+
569
+ return this.saveLock;
570
+ }
571
+
572
+ async _saveAnnotationsInternal(annotations) {
573
+ // Move jsonData outside try block to make it accessible in catch
574
+ console.log(`Saving ${annotations.length} annotations to disk`);
575
+ const jsonData = JSON.stringify(annotations, null, 2);
576
+
577
+ try {
578
+ // Ensure directory exists right before operations
579
+ const dataDir = path.dirname(DATA_FILE);
580
+ if (!existsSync(dataDir)) {
581
+ console.log(`Creating data directory: ${dataDir}`);
582
+ await mkdir(dataDir, { recursive: true });
583
+ }
584
+
585
+ // Atomic write: write to temp file first, then rename
586
+ const tempFile = DATA_FILE + '.tmp';
587
+ console.log(`Writing temp file: ${tempFile}`);
588
+ await writeFile(tempFile, jsonData);
589
+
590
+ // Rename temp file to actual file (atomic operation)
591
+ console.log(`Renaming ${tempFile} to ${DATA_FILE}`);
592
+ const fs = await import('fs');
593
+ await fs.promises.rename(tempFile, DATA_FILE);
594
+
595
+ console.log(`Successfully saved ${annotations.length} annotations to ${DATA_FILE}`);
596
+ } catch (error) {
597
+ console.error('Error saving annotations:', error);
598
+
599
+ // Clean up temp file if it exists
600
+ const tempFile = DATA_FILE + '.tmp';
601
+ try {
602
+ if (existsSync(tempFile)) {
603
+ const fs = await import('fs');
604
+ await fs.promises.unlink(tempFile);
605
+ console.log(`Cleaned up temp file: ${tempFile}`);
606
+ }
607
+ } catch (cleanupError) {
608
+ console.warn(`Failed to clean up temp file: ${cleanupError.message}`);
609
+ }
610
+
611
+ // Fallback: try direct write without atomic operation
612
+ console.log('Attempting fallback direct write...');
613
+ try {
614
+ await writeFile(DATA_FILE, jsonData);
615
+ console.log(`Fallback write successful: ${DATA_FILE}`);
616
+ return;
617
+ } catch (fallbackError) {
618
+ console.error('Fallback write also failed:', fallbackError);
619
+ }
620
+
621
+ throw error;
622
+ }
623
+ }
624
+
625
+ async ensureDataFile() {
626
+ const dataDir = path.dirname(DATA_FILE);
627
+ if (!existsSync(dataDir)) {
628
+ console.log(`Creating data directory: ${dataDir}`);
629
+ await mkdir(dataDir, { recursive: true });
630
+ }
631
+
632
+ if (!existsSync(DATA_FILE)) {
633
+ console.log(`Creating new annotation file: ${DATA_FILE}`);
634
+ await writeFile(DATA_FILE, JSON.stringify([], null, 2));
635
+ } else {
636
+ // File exists - log current annotation count for verification
637
+ try {
638
+ const existingData = await readFile(DATA_FILE, 'utf8');
639
+ const annotations = JSON.parse(existingData || '[]');
640
+ console.log(`Annotation file exists with ${annotations.length} annotations`);
641
+ } catch (error) {
642
+ console.warn(`Warning: Could not read existing annotation file: ${error.message}`);
643
+ }
644
+ }
645
+ }
646
+
647
+ // MCP Tool implementations
648
+ async readAnnotations(args) {
649
+ const annotations = await this.loadAnnotations();
650
+ const { status = 'pending', limit = 50, url } = args;
651
+
652
+ let filtered = annotations;
653
+
654
+ if (status !== 'all') {
655
+ filtered = filtered.filter(a => a.status === status);
656
+ }
657
+
658
+ if (url) {
659
+ // Support both exact URL matching and base URL pattern matching
660
+ if (url.includes('*') || url.endsWith('/')) {
661
+ // Pattern matching: "http://localhost:3000/*" or "http://localhost:3000/"
662
+ const baseUrl = url.replace('*', '').replace(/\/$/, '');
663
+ filtered = filtered.filter(a => a.url.startsWith(baseUrl));
664
+ } else {
665
+ // Exact URL matching
666
+ filtered = filtered.filter(a => a.url === url);
667
+ }
668
+ }
669
+
670
+ // Group annotations by base URL for better context
671
+ const groupedByProject = {};
672
+ filtered.forEach(annotation => {
673
+ try {
674
+ const urlObj = new URL(annotation.url);
675
+ const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
676
+ if (!groupedByProject[baseUrl]) {
677
+ groupedByProject[baseUrl] = [];
678
+ }
679
+ groupedByProject[baseUrl].push(annotation);
680
+ } catch (e) {
681
+ // Handle invalid URLs gracefully
682
+ }
683
+ });
684
+
685
+ // Add project context to response
686
+ const projectCount = Object.keys(groupedByProject).length;
687
+ let multiProjectWarning = null;
688
+
689
+ if (projectCount > 1 && !url) {
690
+ const projectSuggestions = Object.keys(groupedByProject).map(baseUrl => `"${baseUrl}/*"`).join(' or ');
691
+ multiProjectWarning = {
692
+ warning: `MULTI-PROJECT DETECTED: Found annotations from ${projectCount} different projects. This may cause cross-project contamination.`,
693
+ recommendation: `Use the 'url' parameter to filter annotations for your current project.`,
694
+ suggested_filters: Object.keys(groupedByProject).map(baseUrl => `${baseUrl}/*`),
695
+ guidance: `Example: Use url: "${Object.keys(groupedByProject)[0]}/*" to filter for the first project.`,
696
+ projects_detected: Object.keys(groupedByProject)
697
+ };
698
+ console.warn(`MULTI-PROJECT WARNING: Found annotations from ${projectCount} different projects. Use url parameter: ${projectSuggestions}`);
699
+ }
700
+
701
+ // Build project info for better context
702
+ const projectInfo = Object.entries(groupedByProject).map(([baseUrl, annotations]) => ({
703
+ base_url: baseUrl,
704
+ annotation_count: annotations.length,
705
+ paths: [...new Set(annotations.map(a => new URL(a.url).pathname))].slice(0, 5), // Show up to 5 unique paths
706
+ recommended_filter: `${baseUrl}/*`
707
+ }));
708
+
709
+ return {
710
+ annotations: filtered.slice(0, limit),
711
+ projectInfo: projectInfo,
712
+ multiProjectWarning: multiProjectWarning
713
+ };
714
+ }
715
+
716
+ async deleteAnnotation(args) {
717
+ const { id } = args;
718
+
719
+ const annotations = await this.loadAnnotations();
720
+ const index = annotations.findIndex(a => a.id === id);
721
+
722
+ if (index === -1) {
723
+ throw new Error(`Annotation with id ${id} not found`);
724
+ }
725
+
726
+ const deletedAnnotation = annotations[index];
727
+ annotations.splice(index, 1); // Remove the annotation completely
728
+
729
+ await this.saveAnnotations(annotations);
730
+
731
+ return {
732
+ id,
733
+ deleted: true,
734
+ message: `Annotation ${id} has been successfully deleted`,
735
+ deletedAnnotation
736
+ };
737
+ }
738
+
739
+ async deleteProjectAnnotations(args) {
740
+ const { url_pattern, confirm = false } = args;
741
+
742
+ const annotations = await this.loadAnnotations();
743
+
744
+ // Filter annotations matching the URL pattern
745
+ let matchingAnnotations;
746
+ if (url_pattern.includes('*') || url_pattern.endsWith('/')) {
747
+ // Pattern matching: "http://localhost:3000/*" or "http://localhost:3000/"
748
+ const baseUrl = url_pattern.replace('*', '').replace(/\/$/, '');
749
+ matchingAnnotations = annotations.filter(a => a.url.startsWith(baseUrl));
750
+ } else {
751
+ // Exact URL matching
752
+ matchingAnnotations = annotations.filter(a => a.url === url_pattern);
753
+ }
754
+
755
+ if (matchingAnnotations.length === 0) {
756
+ return {
757
+ url_pattern,
758
+ count: 0,
759
+ message: 'No annotations found matching the URL pattern',
760
+ deleted: false
761
+ };
762
+ }
763
+
764
+ // If confirm is false, return preview of what would be deleted
765
+ if (!confirm) {
766
+ const projectInfo = matchingAnnotations.reduce((acc, annotation) => {
767
+ const url = annotation.url;
768
+ if (!acc[url]) {
769
+ acc[url] = [];
770
+ }
771
+ acc[url].push({
772
+ id: annotation.id,
773
+ comment: annotation.comment.substring(0, 100) + (annotation.comment.length > 100 ? '...' : ''),
774
+ created_at: annotation.created_at
775
+ });
776
+ return acc;
777
+ }, {});
778
+
779
+ return {
780
+ url_pattern,
781
+ count: matchingAnnotations.length,
782
+ preview: projectInfo,
783
+ message: `Found ${matchingAnnotations.length} annotation(s) that would be deleted. Set confirm=true to proceed with deletion.`,
784
+ deleted: false,
785
+ urls_affected: Object.keys(projectInfo)
786
+ };
787
+ }
788
+
789
+ // Proceed with deletion
790
+ const remainingAnnotations = annotations.filter(a => !matchingAnnotations.find(m => m.id === a.id));
791
+ await this.saveAnnotations(remainingAnnotations);
792
+
793
+ const deletedInfo = matchingAnnotations.map(a => ({
794
+ id: a.id,
795
+ url: a.url,
796
+ comment: a.comment.substring(0, 100) + (a.comment.length > 100 ? '...' : '')
797
+ }));
798
+
799
+ return {
800
+ url_pattern,
801
+ count: matchingAnnotations.length,
802
+ deleted: true,
803
+ message: `Successfully deleted ${matchingAnnotations.length} annotation(s) for project ${url_pattern}`,
804
+ deleted_annotations: deletedInfo,
805
+ remaining_total: remainingAnnotations.length
806
+ };
807
+ }
808
+
809
+ async getProjectContext(args) {
810
+ const { url } = args;
811
+
812
+ // Parse localhost URL to infer project structure
813
+ const urlObj = new URL(url);
814
+ const port = urlObj.port;
815
+ const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
816
+
817
+ const commonPorts = {
818
+ '3000': 'React/Next.js',
819
+ '5173': 'Vite',
820
+ '8080': 'Vue/Webpack Dev Server',
821
+ '4200': 'Angular',
822
+ '3001': 'Express/Node.js'
823
+ };
824
+
825
+ // Get current working directory context
826
+ const cwd = process.cwd();
827
+ const workingDirectory = {
828
+ path: cwd,
829
+ name: path.basename(cwd)
830
+ };
831
+
832
+ // Try to read package.json for additional context
833
+ let packageInfo = null;
834
+ try {
835
+ const packageJsonPath = path.join(cwd, 'package.json');
836
+ if (existsSync(packageJsonPath)) {
837
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
838
+ packageInfo = {
839
+ name: packageJson.name,
840
+ scripts: Object.keys(packageJson.scripts || {}),
841
+ dependencies: Object.keys(packageJson.dependencies || {}),
842
+ devDependencies: Object.keys(packageJson.devDependencies || {})
843
+ };
844
+ }
845
+ } catch (error) {
846
+ // Package.json not found or invalid, continue without it
847
+ }
848
+
849
+ // Get all annotations to provide project mapping context
850
+ const annotations = await this.loadAnnotations();
851
+ const projectUrls = [...new Set(annotations.map(a => {
852
+ try {
853
+ const aUrl = new URL(a.url);
854
+ return `${aUrl.protocol}//${aUrl.host}`;
855
+ } catch (e) {
856
+ return null;
857
+ }
858
+ }).filter(Boolean))];
859
+
860
+ // Recommend URL filter pattern for this project
861
+ const recommendedFilter = `${baseUrl}/*`;
862
+
863
+ // Check if current project matches working directory context
864
+ const isCurrentProject = url.includes(baseUrl);
865
+
866
+ return {
867
+ url,
868
+ port,
869
+ base_url: baseUrl,
870
+ likely_framework: commonPorts[port] || 'Unknown',
871
+ working_directory: workingDirectory,
872
+ package_info: packageInfo,
873
+ recommended_filter: recommendedFilter,
874
+ all_project_urls: projectUrls,
875
+ is_current_project: isCurrentProject,
876
+ annotation_guidance: projectUrls.length > 1
877
+ ? `Multiple projects detected (${projectUrls.length}). Use url parameter: "${recommendedFilter}" to filter annotations for this specific project.`
878
+ : 'Single project detected. No URL filtering needed.',
879
+ timestamp: new Date().toISOString()
880
+ };
881
+ }
882
+
883
+
884
+ setupProcessHandlers() {
885
+ if (this.handlersSetup) return;
886
+ this.handlersSetup = true;
887
+
888
+ const gracefulShutdown = async (signal) => {
889
+ if (this.isShuttingDown) return;
890
+ this.isShuttingDown = true;
891
+
892
+ console.log(`\nReceived ${signal}. Shutting down gracefully...`);
893
+
894
+ // Set a force exit timer as a last resort
895
+ const forceExitTimer = setTimeout(() => {
896
+ console.log('Force exiting...');
897
+ process.exit(1);
898
+ }, 5000); // Increased to 5 seconds
899
+
900
+ try {
901
+ // Step 1: Close all MCP transport sessions
902
+ console.log('Closing MCP transport sessions...');
903
+ const transportPromises = Object.entries(this.transports).map(([sessionId, transport]) => {
904
+ return new Promise((resolve) => {
905
+ try {
906
+ if (transport && typeof transport.close === 'function') {
907
+ transport.close();
908
+ }
909
+ delete this.transports[sessionId];
910
+ resolve();
911
+ } catch (error) {
912
+ console.warn(`Error closing transport ${sessionId}:`, error.message);
913
+ resolve();
914
+ }
915
+ });
916
+ });
917
+
918
+ await Promise.all(transportPromises);
919
+ console.log('MCP transports closed');
920
+
921
+ // Step 2: Close all HTTP connections
922
+ console.log('Closing HTTP connections...');
923
+ this.connections.forEach(connection => {
924
+ try {
925
+ connection.destroy();
926
+ } catch (error) {
927
+ console.warn('Error destroying connection:', error.message);
928
+ }
929
+ });
930
+ this.connections.clear();
931
+
932
+ // Step 3: Close the HTTP server
933
+ if (this.server) {
934
+ console.log('Closing HTTP server...');
935
+ await new Promise((resolve) => {
936
+ this.server.close((error) => {
937
+ if (error) {
938
+ console.warn('Error closing server:', error.message);
939
+ }
940
+ resolve();
941
+ });
942
+ });
943
+ console.log('HTTP server closed');
944
+ }
945
+
946
+ // Clean shutdown completed
947
+ clearTimeout(forceExitTimer);
948
+ console.log('Graceful shutdown completed');
949
+ process.exit(0);
950
+
951
+ } catch (error) {
952
+ console.error('Error during graceful shutdown:', error);
953
+ clearTimeout(forceExitTimer);
954
+ process.exit(1);
955
+ }
956
+ };
957
+
958
+ // Handle shutdown signals
959
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
960
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
961
+
962
+ // Handle uncaught exceptions
963
+ process.on('uncaughtException', (error) => {
964
+ console.error('Uncaught exception:', error);
965
+ gracefulShutdown('uncaughtException');
966
+ });
967
+
968
+ process.on('unhandledRejection', (reason, promise) => {
969
+ console.error('Unhandled rejection at:', promise, 'reason:', reason);
970
+ gracefulShutdown('unhandledRejection');
971
+ });
972
+ }
973
+
974
+ async checkForUpdates() {
975
+ try {
976
+ // Check cache first (24hr TTL)
977
+ const updateCacheFile = path.join(DATA_DIR, '.update-check');
978
+ let lastCheck = 0;
979
+
980
+ try {
981
+ if (existsSync(updateCacheFile)) {
982
+ const cacheData = await readFile(updateCacheFile, 'utf8');
983
+ lastCheck = parseInt(cacheData, 10) || 0;
984
+ }
985
+ } catch (error) {
986
+ // Ignore cache read errors
987
+ }
988
+
989
+ // Only check once per day
990
+ if (Date.now() - lastCheck < 86400000) return;
991
+
992
+ // Fetch latest version from NPM registry
993
+ const response = await fetch('https://registry.npmjs.org/vibe-annotations-server/latest', {
994
+ headers: {
995
+ 'User-Agent': 'vibe-annotations-server'
996
+ }
997
+ });
998
+
999
+ // If package not found (404), skip update check
1000
+ if (response.status === 404) {
1001
+ console.log('[Update Check] Package not found in NPM registry yet');
1002
+ await writeFile(updateCacheFile, Date.now().toString());
1003
+ return;
1004
+ }
1005
+
1006
+ if (!response.ok) {
1007
+ console.log(`[Update Check] NPM Registry error: ${response.status}`);
1008
+ return;
1009
+ }
1010
+
1011
+ const data = await response.json();
1012
+ const latestVersion = data.version || packageJson.version;
1013
+
1014
+ // Simple version comparison (assuming semantic versioning)
1015
+ const currentParts = packageJson.version.split('.').map(Number);
1016
+ const latestParts = latestVersion.split('.').map(Number);
1017
+
1018
+ let hasUpdate = false;
1019
+ for (let i = 0; i < 3; i++) {
1020
+ if ((latestParts[i] || 0) > (currentParts[i] || 0)) {
1021
+ hasUpdate = true;
1022
+ break;
1023
+ }
1024
+ if ((latestParts[i] || 0) < (currentParts[i] || 0)) {
1025
+ break;
1026
+ }
1027
+ }
1028
+
1029
+ if (hasUpdate) {
1030
+ console.log(chalk.yellow(`
1031
+ ╔════════════════════════════════════════════════════════════════╗
1032
+ ║ Update available: ${packageJson.version} → ${latestVersion} ║
1033
+ ║ Run: npm update -g vibe-annotations-server ║
1034
+ ╚════════════════════════════════════════════════════════════════╝
1035
+ `));
1036
+ }
1037
+
1038
+ // Save last check timestamp
1039
+ await writeFile(updateCacheFile, Date.now().toString());
1040
+ } catch (error) {
1041
+ // Log error for debugging but don't disrupt user experience
1042
+ console.log(`[Update Check] Failed: ${error.message}`);
1043
+ }
1044
+ }
1045
+
1046
+ async start() {
1047
+ await this.ensureDataFile();
1048
+
1049
+ // Set up process handlers only once
1050
+ this.setupProcessHandlers();
1051
+
1052
+ // Check for updates (non-blocking)
1053
+ this.checkForUpdates().catch(() => {});
1054
+
1055
+ this.server = this.app.listen(PORT, () => {
1056
+ console.log(`Vibe Annotations server running on http://127.0.0.1:${PORT}`);
1057
+ console.log(`SSE Endpoint: http://127.0.0.1:${PORT}/sse`);
1058
+ console.log(`HTTP API: http://127.0.0.1:${PORT}/api/annotations`);
1059
+ console.log(`MCP Endpoint: http://127.0.0.1:${PORT}/mcp`);
1060
+ console.log(`Health: http://127.0.0.1:${PORT}/health`);
1061
+ console.log(`Data: ${DATA_FILE}`);
1062
+ console.log('\nServer ready to handle requests');
1063
+ });
1064
+
1065
+ // Track connections for graceful shutdown
1066
+ this.server.on('connection', (connection) => {
1067
+ this.connections.add(connection);
1068
+
1069
+ connection.on('close', () => {
1070
+ this.connections.delete(connection);
1071
+ });
1072
+
1073
+ connection.on('error', () => {
1074
+ this.connections.delete(connection);
1075
+ });
1076
+ });
1077
+ }
1078
+ }
1079
+
1080
+ // Start server
1081
+ async function main() {
1082
+ try {
1083
+ const server = new LocalAnnotationsServer();
1084
+ await server.start();
1085
+ } catch (error) {
1086
+ console.error('Failed to start server:', error);
1087
+ process.exit(1);
1088
+ }
1089
+ }
1090
+
1091
+ main().catch(console.error);