session-collab-mcp 0.4.7 → 0.5.2

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.
@@ -0,0 +1,686 @@
1
+ // LSP integration tools for symbol analysis and validation
2
+
3
+ import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
+ import type { McpTool, McpToolResult } from '../protocol';
5
+ import { createToolResult } from '../protocol';
6
+ import type { SymbolType, ConflictInfo } from '../../db/types';
7
+ import { storeReferences, analyzeClaimImpact, clearSessionReferences } from '../../db/queries';
8
+ import {
9
+ validateInput,
10
+ analyzeSymbolsSchema,
11
+ validateSymbolsSchema,
12
+ storeReferencesSchema,
13
+ impactAnalysisSchema,
14
+ } from '../schemas';
15
+
16
+ // LSP Symbol Kind mapping (from LSP spec)
17
+ const LSP_SYMBOL_KIND_MAP: Record<number, SymbolType> = {
18
+ 5: 'class', // Class
19
+ 6: 'method', // Method
20
+ 9: 'function', // Constructor
21
+ 12: 'function', // Function
22
+ 13: 'variable', // Variable
23
+ 14: 'variable', // Constant
24
+ 23: 'class', // Struct
25
+ // Map others to 'other'
26
+ };
27
+
28
+ // Input format for LSP symbols (simplified from LSP DocumentSymbol)
29
+ interface LspSymbolInput {
30
+ name: string;
31
+ kind: number; // LSP SymbolKind
32
+ range?: {
33
+ start: { line: number; character: number };
34
+ end: { line: number; character: number };
35
+ };
36
+ children?: LspSymbolInput[];
37
+ }
38
+
39
+ interface FileSymbolInput {
40
+ file: string;
41
+ symbols: LspSymbolInput[];
42
+ }
43
+
44
+ // Reference input for tracking symbol dependencies
45
+ interface SymbolReference {
46
+ symbol: string;
47
+ file: string;
48
+ references: Array<{
49
+ file: string;
50
+ line: number;
51
+ context?: string;
52
+ }>;
53
+ }
54
+
55
+ // Output types
56
+ interface AnalyzedSymbol {
57
+ name: string;
58
+ type: SymbolType;
59
+ file: string;
60
+ conflict_status: 'safe' | 'blocked';
61
+ conflict_info?: {
62
+ session_name: string | null;
63
+ intent: string;
64
+ claim_id: string;
65
+ };
66
+ // Reference impact (which other symbols/files depend on this)
67
+ impact?: {
68
+ references_count: number;
69
+ affected_files: string[];
70
+ };
71
+ }
72
+
73
+ export const lspTools: McpTool[] = [
74
+ {
75
+ name: 'collab_analyze_symbols',
76
+ description: `Analyze LSP symbols for conflict detection and reference impact.
77
+
78
+ WORKFLOW:
79
+ 1. Claude uses LSP.documentSymbol to get symbols from a file
80
+ 2. Claude passes those symbols to this tool
81
+ 3. This tool checks conflicts and returns which symbols are safe/blocked
82
+
83
+ This enables Claude to automatically proceed with safe symbols and skip blocked ones.`,
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ session_id: {
88
+ type: 'string',
89
+ description: 'Your session ID (to exclude your own claims)',
90
+ },
91
+ files: {
92
+ type: 'array',
93
+ items: {
94
+ type: 'object',
95
+ properties: {
96
+ file: { type: 'string', description: 'File path' },
97
+ symbols: {
98
+ type: 'array',
99
+ items: {
100
+ type: 'object',
101
+ properties: {
102
+ name: { type: 'string', description: 'Symbol name' },
103
+ kind: { type: 'number', description: 'LSP SymbolKind' },
104
+ range: {
105
+ type: 'object',
106
+ properties: {
107
+ start: {
108
+ type: 'object',
109
+ properties: {
110
+ line: { type: 'number' },
111
+ character: { type: 'number' },
112
+ },
113
+ },
114
+ end: {
115
+ type: 'object',
116
+ properties: {
117
+ line: { type: 'number' },
118
+ character: { type: 'number' },
119
+ },
120
+ },
121
+ },
122
+ },
123
+ children: {
124
+ type: 'array',
125
+ description: 'Nested symbols (e.g., methods in a class)',
126
+ },
127
+ },
128
+ required: ['name', 'kind'],
129
+ },
130
+ description: 'LSP DocumentSymbol array from LSP.documentSymbol',
131
+ },
132
+ },
133
+ required: ['file', 'symbols'],
134
+ },
135
+ description: 'Files with their LSP symbols to analyze',
136
+ },
137
+ references: {
138
+ type: 'array',
139
+ items: {
140
+ type: 'object',
141
+ properties: {
142
+ symbol: { type: 'string', description: 'Symbol name' },
143
+ file: { type: 'string', description: 'File containing the symbol' },
144
+ references: {
145
+ type: 'array',
146
+ items: {
147
+ type: 'object',
148
+ properties: {
149
+ file: { type: 'string' },
150
+ line: { type: 'number' },
151
+ context: { type: 'string' },
152
+ },
153
+ required: ['file', 'line'],
154
+ },
155
+ description: 'Locations that reference this symbol (from LSP.findReferences)',
156
+ },
157
+ },
158
+ required: ['symbol', 'file', 'references'],
159
+ },
160
+ description: 'Optional: Reference data from LSP.findReferences for impact analysis',
161
+ },
162
+ check_symbols: {
163
+ type: 'array',
164
+ items: { type: 'string' },
165
+ description: 'Optional: Only analyze these specific symbol names (filter)',
166
+ },
167
+ },
168
+ required: ['session_id', 'files'],
169
+ },
170
+ },
171
+ {
172
+ name: 'collab_validate_symbols',
173
+ description: `Validate that symbols exist in a file before claiming them.
174
+
175
+ Use this to verify symbol names are correct before calling collab_claim.
176
+ Helps prevent claiming non-existent or misspelled symbols.`,
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ file: {
181
+ type: 'string',
182
+ description: 'File path to validate symbols in',
183
+ },
184
+ symbols: {
185
+ type: 'array',
186
+ items: { type: 'string' },
187
+ description: 'Symbol names to validate',
188
+ },
189
+ lsp_symbols: {
190
+ type: 'array',
191
+ items: {
192
+ type: 'object',
193
+ properties: {
194
+ name: { type: 'string' },
195
+ kind: { type: 'number' },
196
+ },
197
+ required: ['name', 'kind'],
198
+ },
199
+ description: 'LSP symbols from the file (from LSP.documentSymbol)',
200
+ },
201
+ },
202
+ required: ['file', 'symbols', 'lsp_symbols'],
203
+ },
204
+ },
205
+ {
206
+ name: 'collab_store_references',
207
+ description: `Store symbol reference data from LSP.findReferences for impact tracking.
208
+
209
+ Call this after using LSP.findReferences to persist the reference data.
210
+ This enables automatic impact warnings when other sessions claim related files.`,
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ session_id: {
215
+ type: 'string',
216
+ description: 'Your session ID',
217
+ },
218
+ references: {
219
+ type: 'array',
220
+ items: {
221
+ type: 'object',
222
+ properties: {
223
+ source_file: { type: 'string', description: 'File containing the symbol definition' },
224
+ source_symbol: { type: 'string', description: 'Symbol name' },
225
+ references: {
226
+ type: 'array',
227
+ items: {
228
+ type: 'object',
229
+ properties: {
230
+ file: { type: 'string' },
231
+ line: { type: 'number' },
232
+ context: { type: 'string' },
233
+ },
234
+ required: ['file', 'line'],
235
+ },
236
+ },
237
+ },
238
+ required: ['source_file', 'source_symbol', 'references'],
239
+ },
240
+ description: 'Reference data from LSP.findReferences',
241
+ },
242
+ clear_existing: {
243
+ type: 'boolean',
244
+ description: 'Clear existing references from this session before storing (default: false)',
245
+ },
246
+ },
247
+ required: ['session_id', 'references'],
248
+ },
249
+ },
250
+ {
251
+ name: 'collab_impact_analysis',
252
+ description: `Analyze the impact of modifying a symbol.
253
+
254
+ Returns which files reference this symbol and if any of those files have active claims.
255
+ Use this before making changes to widely-used symbols.`,
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ session_id: {
260
+ type: 'string',
261
+ description: 'Your session ID (to exclude your own claims)',
262
+ },
263
+ file: {
264
+ type: 'string',
265
+ description: 'File containing the symbol',
266
+ },
267
+ symbol: {
268
+ type: 'string',
269
+ description: 'Symbol name to analyze',
270
+ },
271
+ },
272
+ required: ['session_id', 'file', 'symbol'],
273
+ },
274
+ },
275
+ ];
276
+
277
+ // Helper: Convert LSP SymbolKind to our SymbolType
278
+ function lspKindToSymbolType(kind: number): SymbolType {
279
+ return LSP_SYMBOL_KIND_MAP[kind] ?? 'other';
280
+ }
281
+
282
+ // Helper: Flatten nested LSP symbols into a flat list
283
+ function flattenLspSymbols(
284
+ symbols: LspSymbolInput[],
285
+ file: string,
286
+ parentName?: string
287
+ ): Array<{ name: string; type: SymbolType; file: string; fullName: string }> {
288
+ const result: Array<{ name: string; type: SymbolType; file: string; fullName: string }> = [];
289
+
290
+ for (const sym of symbols) {
291
+ const fullName = parentName ? `${parentName}.${sym.name}` : sym.name;
292
+ result.push({
293
+ name: sym.name,
294
+ type: lspKindToSymbolType(sym.kind),
295
+ file,
296
+ fullName,
297
+ });
298
+
299
+ // Recursively flatten children (e.g., methods in a class)
300
+ if (sym.children && sym.children.length > 0) {
301
+ result.push(...flattenLspSymbols(sym.children, file, fullName));
302
+ }
303
+ }
304
+
305
+ return result;
306
+ }
307
+
308
+ export async function handleLspTool(
309
+ db: DatabaseAdapter,
310
+ name: string,
311
+ args: Record<string, unknown>
312
+ ): Promise<McpToolResult> {
313
+ switch (name) {
314
+ case 'collab_analyze_symbols': {
315
+ const validation = validateInput(analyzeSymbolsSchema, args);
316
+ if (!validation.success) {
317
+ return createToolResult(
318
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
319
+ true
320
+ );
321
+ }
322
+ const input = validation.data;
323
+ const files = input.files as FileSymbolInput[];
324
+ const references = input.references as SymbolReference[] | undefined;
325
+ const checkSymbols = input.check_symbols;
326
+
327
+ // Build reference lookup map if provided
328
+ const referenceMap = new Map<string, SymbolReference>();
329
+ if (references) {
330
+ for (const ref of references) {
331
+ const key = `${ref.file}:${ref.symbol}`;
332
+ referenceMap.set(key, ref);
333
+ }
334
+ }
335
+
336
+ // Flatten all symbols from all files
337
+ const allSymbols: Array<{ name: string; type: SymbolType; file: string; fullName: string }> = [];
338
+ for (const fileInput of files) {
339
+ const flattened = flattenLspSymbols(fileInput.symbols, fileInput.file);
340
+ allSymbols.push(...flattened);
341
+ }
342
+
343
+ // Filter to only requested symbols if specified
344
+ let symbolsToAnalyze = allSymbols;
345
+ if (checkSymbols && checkSymbols.length > 0) {
346
+ const checkSet = new Set(checkSymbols);
347
+ symbolsToAnalyze = allSymbols.filter((s) => checkSet.has(s.name) || checkSet.has(s.fullName));
348
+ }
349
+
350
+ // Query existing claims for these files
351
+ const fileList = [...new Set(symbolsToAnalyze.map((s) => s.file))];
352
+ const symbolNames = [...new Set(symbolsToAnalyze.map((s) => s.name))];
353
+
354
+ // Check for symbol-level claims
355
+ const symbolConflicts = await querySymbolConflicts(db, fileList, symbolNames, input.session_id);
356
+
357
+ // Check for file-level claims
358
+ const fileConflicts = await queryFileConflicts(db, fileList, input.session_id);
359
+
360
+ // Build result with conflict status for each symbol
361
+ const analyzedSymbols: AnalyzedSymbol[] = [];
362
+ const safeSymbols: string[] = [];
363
+ const blockedSymbols: string[] = [];
364
+
365
+ for (const sym of symbolsToAnalyze) {
366
+ // Check if blocked by file-level claim
367
+ const fileConflict = fileConflicts.find((c) => c.file_path === sym.file);
368
+ // Check if blocked by symbol-level claim
369
+ const symbolConflict = symbolConflicts.find(
370
+ (c) => c.file_path === sym.file && c.symbol_name === sym.name
371
+ );
372
+
373
+ const conflict = fileConflict ?? symbolConflict;
374
+ const isBlocked = !!conflict;
375
+
376
+ // Get reference impact if available
377
+ const refKey = `${sym.file}:${sym.name}`;
378
+ const refData = referenceMap.get(refKey);
379
+ let impact: AnalyzedSymbol['impact'] | undefined;
380
+
381
+ if (refData && refData.references.length > 0) {
382
+ const affectedFiles = [...new Set(refData.references.map((r) => r.file))];
383
+ impact = {
384
+ references_count: refData.references.length,
385
+ affected_files: affectedFiles,
386
+ };
387
+ }
388
+
389
+ const analyzed: AnalyzedSymbol = {
390
+ name: sym.name,
391
+ type: sym.type,
392
+ file: sym.file,
393
+ conflict_status: isBlocked ? 'blocked' : 'safe',
394
+ ...(conflict && {
395
+ conflict_info: {
396
+ session_name: conflict.session_name,
397
+ intent: conflict.intent,
398
+ claim_id: conflict.claim_id,
399
+ },
400
+ }),
401
+ ...(impact && { impact }),
402
+ };
403
+
404
+ analyzedSymbols.push(analyzed);
405
+
406
+ if (isBlocked) {
407
+ blockedSymbols.push(`${sym.file}:${sym.name}`);
408
+ } else {
409
+ safeSymbols.push(`${sym.file}:${sym.name}`);
410
+ }
411
+ }
412
+
413
+ // Determine recommendation
414
+ const hasBlocked = blockedSymbols.length > 0;
415
+ const hasSafe = safeSymbols.length > 0;
416
+
417
+ let recommendation: 'proceed_all' | 'proceed_safe_only' | 'abort';
418
+ if (!hasBlocked) {
419
+ recommendation = 'proceed_all';
420
+ } else if (hasSafe) {
421
+ recommendation = 'proceed_safe_only';
422
+ } else {
423
+ recommendation = 'abort';
424
+ }
425
+
426
+ // Build message
427
+ let message: string;
428
+ if (recommendation === 'proceed_all') {
429
+ message = `All ${safeSymbols.length} symbols are safe to edit. Proceed.`;
430
+ } else if (recommendation === 'proceed_safe_only') {
431
+ message = `Edit ONLY ${safeSymbols.length} safe symbols. ${blockedSymbols.length} symbols are blocked.`;
432
+ } else {
433
+ message = `All ${blockedSymbols.length} symbols are blocked. Coordinate with other sessions.`;
434
+ }
435
+
436
+ return createToolResult(
437
+ JSON.stringify(
438
+ {
439
+ can_edit: hasSafe,
440
+ recommendation,
441
+ summary: {
442
+ total: symbolsToAnalyze.length,
443
+ safe: safeSymbols.length,
444
+ blocked: blockedSymbols.length,
445
+ },
446
+ symbols: analyzedSymbols,
447
+ safe_symbols: safeSymbols,
448
+ blocked_symbols: blockedSymbols,
449
+ message,
450
+ },
451
+ null,
452
+ 2
453
+ )
454
+ );
455
+ }
456
+
457
+ case 'collab_validate_symbols': {
458
+ const validation = validateInput(validateSymbolsSchema, args);
459
+ if (!validation.success) {
460
+ return createToolResult(
461
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
462
+ true
463
+ );
464
+ }
465
+ const input = validation.data;
466
+
467
+ // Build set of available symbol names from LSP data
468
+ const availableSymbols = new Set<string>();
469
+ for (const lspSym of input.lsp_symbols) {
470
+ availableSymbols.add(lspSym.name);
471
+ }
472
+
473
+ // Validate each requested symbol
474
+ const valid: string[] = [];
475
+ const invalid: string[] = [];
476
+ const suggestions: Record<string, string[]> = {};
477
+
478
+ for (const sym of input.symbols) {
479
+ if (availableSymbols.has(sym)) {
480
+ valid.push(sym);
481
+ } else {
482
+ invalid.push(sym);
483
+ // Find similar names (simple prefix/suffix matching)
484
+ const similar = Array.from(availableSymbols).filter(
485
+ (avail) =>
486
+ avail.toLowerCase().includes(sym.toLowerCase()) ||
487
+ sym.toLowerCase().includes(avail.toLowerCase())
488
+ );
489
+ if (similar.length > 0) {
490
+ suggestions[sym] = similar.slice(0, 3);
491
+ }
492
+ }
493
+ }
494
+
495
+ const allValid = invalid.length === 0;
496
+
497
+ return createToolResult(
498
+ JSON.stringify({
499
+ valid: allValid,
500
+ file: input.file,
501
+ valid_symbols: valid,
502
+ invalid_symbols: invalid,
503
+ suggestions: Object.keys(suggestions).length > 0 ? suggestions : undefined,
504
+ available_symbols: Array.from(availableSymbols),
505
+ message: allValid
506
+ ? `All ${valid.length} symbols are valid.`
507
+ : `${invalid.length} symbol(s) not found: ${invalid.join(', ')}`,
508
+ })
509
+ );
510
+ }
511
+
512
+ case 'collab_store_references': {
513
+ const validation = validateInput(storeReferencesSchema, args);
514
+ if (!validation.success) {
515
+ return createToolResult(
516
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
517
+ true
518
+ );
519
+ }
520
+ const input = validation.data;
521
+
522
+ // Clear existing references if requested
523
+ let cleared = 0;
524
+ if (input.clear_existing) {
525
+ cleared = await clearSessionReferences(db, input.session_id);
526
+ }
527
+
528
+ // Store new references
529
+ const result = await storeReferences(db, input.session_id, input.references);
530
+
531
+ return createToolResult(
532
+ JSON.stringify({
533
+ success: true,
534
+ stored: result.stored,
535
+ skipped: result.skipped,
536
+ cleared: cleared,
537
+ message: `Stored ${result.stored} references${cleared > 0 ? ` (cleared ${cleared} existing)` : ''}.`,
538
+ })
539
+ );
540
+ }
541
+
542
+ case 'collab_impact_analysis': {
543
+ const validation = validateInput(impactAnalysisSchema, args);
544
+ if (!validation.success) {
545
+ return createToolResult(
546
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
547
+ true
548
+ );
549
+ }
550
+ const input = validation.data;
551
+
552
+ const impact = await analyzeClaimImpact(db, input.file, input.symbol, input.session_id);
553
+
554
+ // Determine risk level
555
+ let riskLevel: 'low' | 'medium' | 'high';
556
+ if (impact.affected_claims.length > 0) {
557
+ riskLevel = 'high';
558
+ } else if (impact.reference_count > 10) {
559
+ riskLevel = 'medium';
560
+ } else {
561
+ riskLevel = 'low';
562
+ }
563
+
564
+ // Build message
565
+ let message: string;
566
+ if (impact.affected_claims.length > 0) {
567
+ const claimNames = impact.affected_claims.map((c) => c.session_name ?? 'unknown').join(', ');
568
+ message = `⚠️ HIGH RISK: ${impact.affected_claims.length} active claim(s) on referencing files (${claimNames}). Coordinate before modifying.`;
569
+ } else if (impact.reference_count > 0) {
570
+ message = `${impact.reference_count} references across ${impact.affected_files.length} file(s). No active claims conflict.`;
571
+ } else {
572
+ message = 'No stored references found. Consider running LSP.findReferences and collab_store_references first.';
573
+ }
574
+
575
+ return createToolResult(
576
+ JSON.stringify(
577
+ {
578
+ symbol: impact.symbol,
579
+ file: impact.file,
580
+ risk_level: riskLevel,
581
+ reference_count: impact.reference_count,
582
+ affected_files: impact.affected_files,
583
+ affected_claims: impact.affected_claims,
584
+ message,
585
+ },
586
+ null,
587
+ 2
588
+ )
589
+ );
590
+ }
591
+
592
+ default:
593
+ return createToolResult(`Unknown LSP tool: ${name}`, true);
594
+ }
595
+ }
596
+
597
+ // Helper: Query symbol-level conflicts from database
598
+ async function querySymbolConflicts(
599
+ db: DatabaseAdapter,
600
+ files: string[],
601
+ symbolNames: string[],
602
+ excludeSessionId: string
603
+ ): Promise<ConflictInfo[]> {
604
+ if (files.length === 0 || symbolNames.length === 0) {
605
+ return [];
606
+ }
607
+
608
+ const filePlaceholders = files.map(() => '?').join(',');
609
+ const symbolPlaceholders = symbolNames.map(() => '?').join(',');
610
+
611
+ const query = `
612
+ SELECT
613
+ c.id as claim_id,
614
+ c.session_id,
615
+ s.name as session_name,
616
+ cs.file_path,
617
+ c.intent,
618
+ c.scope,
619
+ c.created_at,
620
+ cs.symbol_name,
621
+ cs.symbol_type
622
+ FROM claim_symbols cs
623
+ JOIN claims c ON cs.claim_id = c.id
624
+ JOIN sessions s ON c.session_id = s.id
625
+ WHERE c.status = 'active'
626
+ AND s.status = 'active'
627
+ AND c.session_id != ?
628
+ AND cs.file_path IN (${filePlaceholders})
629
+ AND cs.symbol_name IN (${symbolPlaceholders})
630
+ `;
631
+
632
+ const result = await db
633
+ .prepare(query)
634
+ .bind(excludeSessionId, ...files, ...symbolNames)
635
+ .all<ConflictInfo & { symbol_name: string; symbol_type: string }>();
636
+
637
+ return result.results.map((r) => ({
638
+ ...r,
639
+ conflict_level: 'symbol' as const,
640
+ }));
641
+ }
642
+
643
+ // Helper: Query file-level conflicts from database
644
+ async function queryFileConflicts(
645
+ db: DatabaseAdapter,
646
+ files: string[],
647
+ excludeSessionId: string
648
+ ): Promise<ConflictInfo[]> {
649
+ if (files.length === 0) {
650
+ return [];
651
+ }
652
+
653
+ const placeholders = files.map(() => '?').join(',');
654
+
655
+ // File-level claims are claims that have files but no symbols for those files
656
+ const query = `
657
+ SELECT
658
+ c.id as claim_id,
659
+ c.session_id,
660
+ s.name as session_name,
661
+ cf.file_path,
662
+ c.intent,
663
+ c.scope,
664
+ c.created_at
665
+ FROM claim_files cf
666
+ JOIN claims c ON cf.claim_id = c.id
667
+ JOIN sessions s ON c.session_id = s.id
668
+ WHERE c.status = 'active'
669
+ AND s.status = 'active'
670
+ AND c.session_id != ?
671
+ AND cf.file_path IN (${placeholders})
672
+ AND NOT EXISTS (
673
+ SELECT 1 FROM claim_symbols cs WHERE cs.claim_id = c.id AND cs.file_path = cf.file_path
674
+ )
675
+ `;
676
+
677
+ const result = await db
678
+ .prepare(query)
679
+ .bind(excludeSessionId, ...files)
680
+ .all<Omit<ConflictInfo, 'conflict_level'>>();
681
+
682
+ return result.results.map((r) => ({
683
+ ...r,
684
+ conflict_level: 'file' as const,
685
+ }));
686
+ }