session-collab-mcp 0.4.6 → 0.5.0

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.
@@ -39,7 +39,8 @@ class SqlitePreparedStatement implements PreparedStatement {
39
39
 
40
40
  constructor(
41
41
  private db: Database.Database,
42
- private sql: string
42
+ private sql: string,
43
+ private onWrite?: () => void
43
44
  ) {}
44
45
 
45
46
  bind(...values: unknown[]): PreparedStatement {
@@ -65,6 +66,8 @@ class SqlitePreparedStatement implements PreparedStatement {
65
66
  async run(): Promise<{ meta: { changes: number } }> {
66
67
  const stmt = this.db.prepare(this.sql);
67
68
  const result = stmt.run(...this.bindings);
69
+ // Trigger checkpoint after write
70
+ this.onWrite?.();
68
71
  return {
69
72
  meta: { changes: result.changes },
70
73
  };
@@ -88,12 +91,25 @@ class SqliteDatabase implements DatabaseAdapter {
88
91
  }
89
92
 
90
93
  this.db = new Database(dbPath);
94
+
95
+ // Multi-process SQLite configuration
91
96
  this.db.pragma('journal_mode = WAL');
92
97
  this.db.pragma('foreign_keys = ON');
98
+ this.db.pragma('busy_timeout = 5000'); // Wait up to 5s for locks
99
+ this.db.pragma('synchronous = NORMAL'); // Ensure durability
100
+ this.db.pragma('wal_autocheckpoint = 100'); // Checkpoint every 100 pages
101
+
102
+ // Checkpoint on open to see latest data from other processes
103
+ this.db.pragma('wal_checkpoint(PASSIVE)');
104
+ }
105
+
106
+ // Force checkpoint to make changes visible to other processes
107
+ checkpoint(): void {
108
+ this.db.pragma('wal_checkpoint(PASSIVE)');
93
109
  }
94
110
 
95
111
  prepare(sql: string): PreparedStatement {
96
- return new SqlitePreparedStatement(this.db, sql);
112
+ return new SqlitePreparedStatement(this.db, sql, () => this.checkpoint());
97
113
  }
98
114
 
99
115
  async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
@@ -107,7 +123,10 @@ class SqliteDatabase implements DatabaseAdapter {
107
123
  };
108
124
  });
109
125
  });
110
- return transaction();
126
+ const results = transaction();
127
+ // Checkpoint after batch write
128
+ this.checkpoint();
129
+ return results;
111
130
  }
112
131
 
113
132
  // Initialize database schema
package/src/db/types.ts CHANGED
@@ -120,6 +120,62 @@ export interface ClaimFile {
120
120
  is_pattern: number; // 0 or 1, SQLite boolean
121
121
  }
122
122
 
123
+ // Symbol types for fine-grained conflict detection
124
+ export type SymbolType = 'function' | 'class' | 'method' | 'variable' | 'block' | 'other';
125
+
126
+ export interface ClaimSymbol {
127
+ id: number;
128
+ claim_id: string;
129
+ file_path: string;
130
+ symbol_name: string;
131
+ symbol_type: SymbolType;
132
+ created_at: string;
133
+ }
134
+
135
+ // Input format for claiming symbols
136
+ export interface SymbolClaim {
137
+ file: string;
138
+ symbols: string[];
139
+ symbol_type?: SymbolType;
140
+ }
141
+
142
+ // Symbol reference for impact tracking
143
+ export interface SymbolReference {
144
+ id: number;
145
+ source_file: string;
146
+ source_symbol: string;
147
+ ref_file: string;
148
+ ref_line: number | null;
149
+ ref_context: string | null;
150
+ session_id: string;
151
+ created_at: string;
152
+ }
153
+
154
+ // Input format for storing references
155
+ export interface ReferenceInput {
156
+ source_file: string;
157
+ source_symbol: string;
158
+ references: Array<{
159
+ file: string;
160
+ line: number;
161
+ context?: string;
162
+ }>;
163
+ }
164
+
165
+ // Impact analysis result
166
+ export interface ImpactInfo {
167
+ symbol: string;
168
+ file: string;
169
+ affected_claims: Array<{
170
+ claim_id: string;
171
+ session_name: string | null;
172
+ intent: string;
173
+ affected_symbols: string[];
174
+ }>;
175
+ reference_count: number;
176
+ affected_files: string[];
177
+ }
178
+
123
179
  export interface Message {
124
180
  id: string;
125
181
  from_session_id: string;
@@ -152,4 +208,8 @@ export interface ConflictInfo {
152
208
  intent: string;
153
209
  scope: ClaimScope;
154
210
  created_at: string;
211
+ // Symbol-level conflict info (optional)
212
+ symbol_name?: string;
213
+ symbol_type?: SymbolType;
214
+ conflict_level: 'file' | 'symbol';
155
215
  }
package/src/mcp/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // MCP Server implementation for Session Collaboration
2
+ // Last edited: 2025-12-29 by session-b
2
3
 
3
4
  import type { DatabaseAdapter } from '../db/sqlite-adapter.js';
4
5
  import {
@@ -18,6 +19,7 @@ import { sessionTools, handleSessionTool } from './tools/session';
18
19
  import { claimTools, handleClaimTool } from './tools/claim';
19
20
  import { messageTools, handleMessageTool } from './tools/message';
20
21
  import { decisionTools, handleDecisionTool } from './tools/decision';
22
+ import { lspTools, handleLspTool } from './tools/lsp';
21
23
  import type { AuthContext } from '../auth/types';
22
24
  import { VERSION, SERVER_NAME, SERVER_INSTRUCTIONS } from '../constants.js';
23
25
 
@@ -31,7 +33,7 @@ const CAPABILITIES: McpCapabilities = {
31
33
  };
32
34
 
33
35
  // Combine all tools
34
- const ALL_TOOLS: McpTool[] = [...sessionTools, ...claimTools, ...messageTools, ...decisionTools];
36
+ const ALL_TOOLS: McpTool[] = [...sessionTools, ...claimTools, ...messageTools, ...decisionTools, ...lspTools];
35
37
 
36
38
  export class McpServer {
37
39
  private authContext?: AuthContext;
@@ -104,6 +106,13 @@ export class McpServer {
104
106
  result = await handleMessageTool(this.db, name, args);
105
107
  } else if (name.startsWith('collab_decision_')) {
106
108
  result = await handleDecisionTool(this.db, name, args);
109
+ } else if (
110
+ name === 'collab_analyze_symbols' ||
111
+ name === 'collab_validate_symbols' ||
112
+ name === 'collab_store_references' ||
113
+ name === 'collab_impact_analysis'
114
+ ) {
115
+ result = await handleLspTool(this.db, name, args);
107
116
  } else {
108
117
  result = createToolResult(`Unknown tool: ${name}`, true);
109
118
  }
@@ -147,6 +156,13 @@ export async function handleMcpRequest(
147
156
  return await handleMessageTool(db, name, args);
148
157
  } else if (name.startsWith('collab_decision_')) {
149
158
  return await handleDecisionTool(db, name, args);
159
+ } else if (
160
+ name === 'collab_analyze_symbols' ||
161
+ name === 'collab_validate_symbols' ||
162
+ name === 'collab_store_references' ||
163
+ name === 'collab_impact_analysis'
164
+ ) {
165
+ return await handleLspTool(db, name, args);
150
166
  } else {
151
167
  return createToolResult(`Unknown tool: ${name}`, true);
152
168
  }
@@ -3,7 +3,7 @@
3
3
  import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
4
  import type { McpTool, McpToolResult } from '../protocol';
5
5
  import { createToolResult } from '../protocol';
6
- import type { ClaimScope, SessionConfig } from '../../db/types';
6
+ import type { ClaimScope, SessionConfig, SymbolClaim } from '../../db/types';
7
7
  import { DEFAULT_SESSION_CONFIG } from '../../db/types';
8
8
  import { createClaim, getClaim, listClaims, checkConflicts, releaseClaim, getSession } from '../../db/queries';
9
9
 
@@ -11,7 +11,7 @@ export const claimTools: McpTool[] = [
11
11
  {
12
12
  name: 'collab_claim',
13
13
  description:
14
- 'Declare files you are about to modify. Other sessions will see a warning before modifying the same files.',
14
+ 'Declare files or specific symbols (functions/classes) you are about to modify. Use symbols for fine-grained claims that allow other sessions to work on different parts of the same file.',
15
15
  inputSchema: {
16
16
  type: 'object',
17
17
  properties: {
@@ -22,11 +22,32 @@ export const claimTools: McpTool[] = [
22
22
  files: {
23
23
  type: 'array',
24
24
  items: { type: 'string' },
25
- description: "File paths to claim. Supports glob patterns like 'src/api/*'",
25
+ description: "File paths to claim. Supports glob patterns like 'src/api/*'. Use this for whole-file claims.",
26
+ },
27
+ symbols: {
28
+ type: 'array',
29
+ items: {
30
+ type: 'object',
31
+ properties: {
32
+ file: { type: 'string', description: 'File path containing the symbols' },
33
+ symbols: {
34
+ type: 'array',
35
+ items: { type: 'string' },
36
+ description: 'Symbol names (function, class, method names) to claim',
37
+ },
38
+ symbol_type: {
39
+ type: 'string',
40
+ enum: ['function', 'class', 'method', 'variable', 'block', 'other'],
41
+ description: 'Type of symbols being claimed (default: function)',
42
+ },
43
+ },
44
+ required: ['file', 'symbols'],
45
+ },
46
+ description: 'Symbol-level claims for fine-grained conflict detection. Use this instead of files when you only need to modify specific functions/classes.',
26
47
  },
27
48
  intent: {
28
49
  type: 'string',
29
- description: 'What you plan to do with these files',
50
+ description: 'What you plan to do with these files/symbols',
30
51
  },
31
52
  scope: {
32
53
  type: 'string',
@@ -34,13 +55,13 @@ export const claimTools: McpTool[] = [
34
55
  description: 'Estimated scope: small(<30min), medium(30min-2hr), large(>2hr)',
35
56
  },
36
57
  },
37
- required: ['session_id', 'files', 'intent'],
58
+ required: ['session_id', 'intent'],
38
59
  },
39
60
  },
40
61
  {
41
62
  name: 'collab_check',
42
63
  description:
43
- 'Check if files are being worked on by other sessions. ALWAYS call this before deleting or significantly modifying files.',
64
+ 'Check if files or symbols are being worked on by other sessions. ALWAYS call this before modifying files. Supports symbol-level checking for fine-grained conflict detection.',
44
65
  inputSchema: {
45
66
  type: 'object',
46
67
  properties: {
@@ -49,6 +70,22 @@ export const claimTools: McpTool[] = [
49
70
  items: { type: 'string' },
50
71
  description: 'File paths to check',
51
72
  },
73
+ symbols: {
74
+ type: 'array',
75
+ items: {
76
+ type: 'object',
77
+ properties: {
78
+ file: { type: 'string', description: 'File path containing the symbols' },
79
+ symbols: {
80
+ type: 'array',
81
+ items: { type: 'string' },
82
+ description: 'Symbol names to check for conflicts',
83
+ },
84
+ },
85
+ required: ['file', 'symbols'],
86
+ },
87
+ description: 'Symbol-level check. If provided, only checks for conflicts with these specific symbols.',
88
+ },
52
89
  session_id: {
53
90
  type: 'string',
54
91
  description: 'Your session ID (to exclude your own claims from results)',
@@ -121,6 +158,7 @@ export async function handleClaimTool(
121
158
  case 'collab_claim': {
122
159
  const sessionId = args.session_id as string | undefined;
123
160
  const files = args.files as string[] | undefined;
161
+ const symbols = args.symbols as SymbolClaim[] | undefined;
124
162
  const intent = args.intent as string | undefined;
125
163
  const scope = (args.scope as ClaimScope) ?? 'medium';
126
164
 
@@ -131,12 +169,18 @@ export async function handleClaimTool(
131
169
  true
132
170
  );
133
171
  }
134
- if (!files || !Array.isArray(files) || files.length === 0) {
172
+
173
+ // Either files or symbols must be provided
174
+ const hasFiles = files && Array.isArray(files) && files.length > 0;
175
+ const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
176
+
177
+ if (!hasFiles && !hasSymbols) {
135
178
  return createToolResult(
136
- JSON.stringify({ error: 'INVALID_INPUT', message: 'files array cannot be empty' }),
179
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'Either files or symbols must be provided' }),
137
180
  true
138
181
  );
139
182
  }
183
+
140
184
  if (!intent || typeof intent !== 'string' || intent.trim() === '') {
141
185
  return createToolResult(
142
186
  JSON.stringify({ error: 'INVALID_INPUT', message: 'intent is required' }),
@@ -156,41 +200,56 @@ export async function handleClaimTool(
156
200
  );
157
201
  }
158
202
 
203
+ // Build file list from both files and symbols
204
+ const allFiles = new Set<string>(files ?? []);
205
+ if (hasSymbols) {
206
+ for (const sc of symbols!) {
207
+ allFiles.add(sc.file);
208
+ }
209
+ }
210
+ const fileList = Array.from(allFiles);
211
+
159
212
  // Check for conflicts before creating claim
160
- const conflicts = await checkConflicts(db, files, sessionId);
213
+ const conflicts = await checkConflicts(db, fileList, sessionId, symbols);
161
214
 
162
215
  // Create the claim
163
216
  const { claim } = await createClaim(db, {
164
217
  session_id: sessionId,
165
- files,
218
+ files: fileList,
166
219
  intent,
167
220
  scope,
221
+ symbols: hasSymbols ? symbols : undefined,
168
222
  });
169
223
 
170
224
  if (conflicts.length > 0) {
171
- // Group conflicts by claim
172
- const conflictsByClaim = new Map<string, typeof conflicts>();
173
- for (const c of conflicts) {
174
- const existing = conflictsByClaim.get(c.claim_id) ?? [];
175
- existing.push(c);
176
- conflictsByClaim.set(c.claim_id, existing);
177
- }
178
-
179
- const conflictDetails = Array.from(conflictsByClaim.entries()).map(([claimId, items]) => ({
180
- claim_id: claimId,
181
- session_name: items[0].session_name,
182
- intent: items[0].intent,
183
- scope: items[0].scope,
184
- overlapping_files: items.map((i) => i.file_path),
185
- }));
225
+ // Group conflicts by type (file vs symbol)
226
+ const fileConflicts = conflicts.filter((c) => c.conflict_level === 'file');
227
+ const symbolConflicts = conflicts.filter((c) => c.conflict_level === 'symbol');
228
+
229
+ const conflictDetails = {
230
+ file_level: fileConflicts.map((c) => ({
231
+ session_name: c.session_name,
232
+ file: c.file_path,
233
+ intent: c.intent,
234
+ })),
235
+ symbol_level: symbolConflicts.map((c) => ({
236
+ session_name: c.session_name,
237
+ file: c.file_path,
238
+ symbol: c.symbol_name,
239
+ symbol_type: c.symbol_type,
240
+ intent: c.intent,
241
+ })),
242
+ };
186
243
 
187
244
  return createToolResult(
188
245
  JSON.stringify(
189
246
  {
190
247
  claim_id: claim.id,
191
248
  status: 'created_with_conflicts',
249
+ files: fileList,
250
+ symbols: hasSymbols ? symbols : undefined,
192
251
  conflicts: conflictDetails,
193
- warning: `⚠️ ${conflicts.length} file(s) overlap with other sessions. Please coordinate before proceeding.`,
252
+ warning: `⚠️ Conflicts detected: ${fileConflicts.length} file-level, ${symbolConflicts.length} symbol-level. Coordinate before proceeding.`,
194
253
  },
195
254
  null,
196
255
  2
@@ -202,16 +261,20 @@ export async function handleClaimTool(
202
261
  JSON.stringify({
203
262
  claim_id: claim.id,
204
263
  status: 'created',
205
- files,
264
+ files: fileList,
265
+ symbols: hasSymbols ? symbols : undefined,
206
266
  intent,
207
267
  scope,
208
- message: 'Claim created successfully. Other sessions will be warned about these files.',
268
+ message: hasSymbols
269
+ ? 'Symbol-level claim created. Other sessions can work on different symbols in the same file.'
270
+ : 'Claim created successfully. Other sessions will be warned about these files.',
209
271
  })
210
272
  );
211
273
  }
212
274
 
213
275
  case 'collab_check': {
214
276
  const files = args.files as string[] | undefined;
277
+ const symbols = args.symbols as SymbolClaim[] | undefined;
215
278
  const sessionId = args.session_id as string | undefined;
216
279
 
217
280
  // Input validation
@@ -246,21 +309,89 @@ export async function handleClaimTool(
246
309
  }
247
310
  }
248
311
 
249
- const conflicts = await checkConflicts(db, files, sessionId);
312
+ const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
313
+ const conflicts = await checkConflicts(db, files, sessionId, hasSymbols ? symbols : undefined);
314
+
315
+ // Separate file-level and symbol-level conflicts
316
+ const fileConflicts = conflicts.filter((c) => c.conflict_level === 'file');
317
+ const symbolConflicts = conflicts.filter((c) => c.conflict_level === 'symbol');
318
+
319
+ // Build per-file status (considering both file and symbol conflicts)
320
+ const blockedFiles = new Set<string>();
321
+ const blockedSymbols = new Map<string, Set<string>>(); // file -> symbols
322
+
323
+ for (const c of conflicts) {
324
+ if (c.conflict_level === 'file') {
325
+ blockedFiles.add(c.file_path);
326
+ } else if (c.conflict_level === 'symbol' && c.symbol_name) {
327
+ const existing = blockedSymbols.get(c.file_path) ?? new Set();
328
+ existing.add(c.symbol_name);
329
+ blockedSymbols.set(c.file_path, existing);
330
+ }
331
+ }
332
+
333
+ // For symbol-level check, determine safe symbols
334
+ let safeSymbols: Array<{ file: string; symbols: string[] }> = [];
335
+ let blockedSymbolsList: Array<{ file: string; symbols: string[] }> = [];
336
+
337
+ if (hasSymbols) {
338
+ for (const sc of symbols!) {
339
+ const blocked = blockedSymbols.get(sc.file) ?? new Set();
340
+ const safe = sc.symbols.filter((s) => !blocked.has(s));
341
+ const blockedList = sc.symbols.filter((s) => blocked.has(s));
342
+
343
+ if (safe.length > 0) {
344
+ safeSymbols.push({ file: sc.file, symbols: safe });
345
+ }
346
+ if (blockedList.length > 0) {
347
+ blockedSymbolsList.push({ file: sc.file, symbols: blockedList });
348
+ }
349
+ }
350
+ }
351
+
352
+ const safeFiles = files.filter((f) => !blockedFiles.has(f) && !blockedSymbols.has(f));
353
+ const blockedFilesList = files.filter((f) => blockedFiles.has(f));
354
+
355
+ // Determine recommendation for Claude's auto-decision
356
+ type Recommendation = 'proceed_all' | 'proceed_safe_only' | 'abort';
357
+ let recommendation: Recommendation;
358
+ let canEdit: boolean;
359
+
360
+ const hasSafeContent = safeFiles.length > 0 || safeSymbols.length > 0;
361
+
362
+ if (conflicts.length === 0) {
363
+ recommendation = 'proceed_all';
364
+ canEdit = true;
365
+ } else if (hasSafeContent) {
366
+ recommendation = 'proceed_safe_only';
367
+ canEdit = true;
368
+ } else {
369
+ recommendation = 'abort';
370
+ canEdit = false;
371
+ }
250
372
 
251
373
  if (conflicts.length === 0) {
252
374
  return createToolResult(
253
375
  JSON.stringify({
254
376
  has_conflicts: false,
255
377
  safe: true,
256
- message: 'These files are not being worked on by other sessions.',
378
+ can_edit: true,
379
+ recommendation: 'proceed_all',
380
+ file_status: {
381
+ safe: files,
382
+ blocked: [],
383
+ },
384
+ symbol_status: hasSymbols ? { safe: symbols, blocked: [] } : undefined,
385
+ message: hasSymbols
386
+ ? 'All symbols are safe to edit. Proceed.'
387
+ : 'All files are safe to edit. Proceed.',
257
388
  has_in_progress_todo: hasInProgressTodo,
258
389
  todos_status: todosStatus,
259
390
  })
260
391
  );
261
392
  }
262
393
 
263
- // Group by session for clearer output
394
+ // Group conflicts by session for clearer output
264
395
  const bySession = new Map<string, typeof conflicts>();
265
396
  for (const c of conflicts) {
266
397
  const key = c.session_id;
@@ -269,22 +400,60 @@ export async function handleClaimTool(
269
400
  bySession.set(key, existing);
270
401
  }
271
402
 
272
- const conflictDetails = Array.from(bySession.entries()).map(([sessId, items]) => ({
273
- session_id: sessId,
274
- session_name: items[0].session_name,
275
- intent: items[0].intent,
276
- scope: items[0].scope,
277
- files: items.map((i) => i.file_path),
278
- started_at: items[0].created_at,
279
- }));
403
+ const conflictDetails = Array.from(bySession.entries()).map(([sessId, items]) => {
404
+ const fileItems = items.filter((i) => i.conflict_level === 'file');
405
+ const symbolItems = items.filter((i) => i.conflict_level === 'symbol');
406
+
407
+ return {
408
+ session_id: sessId,
409
+ session_name: items[0].session_name,
410
+ intent: items[0].intent,
411
+ scope: items[0].scope,
412
+ files: fileItems.map((i) => i.file_path),
413
+ symbols: symbolItems.map((i) => ({
414
+ file: i.file_path,
415
+ symbol: i.symbol_name,
416
+ type: i.symbol_type,
417
+ })),
418
+ started_at: items[0].created_at,
419
+ };
420
+ });
421
+
422
+ // Build actionable message based on recommendation
423
+ let message: string;
424
+ if (recommendation === 'proceed_safe_only') {
425
+ if (hasSymbols && safeSymbols.length > 0) {
426
+ const safeDesc = safeSymbols.map((s) => `${s.file}:[${s.symbols.join(',')}]`).join(', ');
427
+ const blockedDesc = blockedSymbolsList.map((s) => `${s.file}:[${s.symbols.join(',')}]`).join(', ');
428
+ message = `Edit ONLY these safe symbols: ${safeDesc}. Skip blocked: ${blockedDesc}.`;
429
+ } else {
430
+ message = `Edit ONLY these safe files: [${safeFiles.join(', ')}]. Skip blocked files: [${blockedFilesList.join(', ')}].`;
431
+ }
432
+ } else {
433
+ message = hasSymbols
434
+ ? `All requested symbols are blocked. Coordinate with other session(s) or wait.`
435
+ : `All ${files.length} file(s) are blocked. Coordinate with other session(s) or wait.`;
436
+ }
280
437
 
281
438
  return createToolResult(
282
439
  JSON.stringify(
283
440
  {
284
441
  has_conflicts: true,
285
442
  safe: false,
443
+ can_edit: canEdit,
444
+ recommendation,
445
+ file_status: {
446
+ safe: safeFiles,
447
+ blocked: blockedFilesList,
448
+ },
449
+ symbol_status: hasSymbols
450
+ ? {
451
+ safe: safeSymbols,
452
+ blocked: blockedSymbolsList,
453
+ }
454
+ : undefined,
286
455
  conflicts: conflictDetails,
287
- warning: `⚠️ ${conflicts.length} file(s) are being worked on by ${bySession.size} other session(s). Coordinate before modifying.`,
456
+ message,
288
457
  has_in_progress_todo: hasInProgressTodo,
289
458
  todos_status: todosStatus,
290
459
  },