session-collab-mcp 0.4.4 → 0.4.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.
@@ -0,0 +1,2 @@
1
+ -- Add config column to sessions table
2
+ ALTER TABLE sessions ADD COLUMN config TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-collab-mcp",
3
- "version": "0.4.4",
3
+ "version": "0.4.7",
4
4
  "description": "MCP server for Claude Code session collaboration - prevents conflicts between parallel sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  // Authentication API handlers
2
2
 
3
- import type { D1Database } from '../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../db/sqlite-adapter.js';
4
4
  import { hashPassword, verifyPassword, validatePasswordStrength } from './password';
5
5
  import { createAccessToken, createRefreshToken, verifyJwt, getTokenExpiry } from './jwt';
6
6
  import {
@@ -29,7 +29,7 @@ import {
29
29
  } from './types';
30
30
 
31
31
  interface HandlerContext {
32
- db: D1Database;
32
+ db: DatabaseAdapter;
33
33
  jwtSecret: string;
34
34
  request: Request;
35
35
  }
@@ -1,13 +1,13 @@
1
1
  // Authentication middleware
2
2
 
3
- import type { D1Database } from '../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../db/sqlite-adapter.js';
4
4
  import { verifyJwt } from './jwt';
5
5
  import { getApiTokenByHash, updateApiTokenLastUsed } from '../db/auth-queries';
6
6
  import { sha256, timingSafeEqual } from '../utils/crypto';
7
7
  import type { AuthContext } from './types';
8
8
 
9
9
  export interface Env {
10
- DB: D1Database;
10
+ DB: DatabaseAdapter;
11
11
  JWT_SECRET?: string;
12
12
  API_TOKEN?: string; // Legacy single token support
13
13
  }
@@ -52,7 +52,7 @@ export async function validateAuth(request: Request, env: Env): Promise<AuthCont
52
52
  /**
53
53
  * Validate API Token
54
54
  */
55
- async function validateApiToken(token: string, db: D1Database): Promise<AuthContext | null> {
55
+ async function validateApiToken(token: string, db: DatabaseAdapter): Promise<AuthContext | null> {
56
56
  const tokenHash = await sha256(token);
57
57
  const apiToken = await getApiTokenByHash(db, tokenHash);
58
58
 
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { createInterface } from 'readline';
6
6
  import { createLocalDatabase, getDefaultDbPath } from './db/sqlite-adapter.js';
7
7
  import { handleMcpRequest, getMcpTools } from './mcp/server.js';
8
+ import { VERSION, SERVER_NAME, SERVER_INSTRUCTIONS } from './constants.js';
8
9
  import { readFileSync } from 'fs';
9
10
  import { join, dirname } from 'path';
10
11
  import { fileURLToPath } from 'url';
@@ -17,6 +18,7 @@ function loadMigrations(): string[] {
17
18
  return [
18
19
  readFileSync(join(migrationsDir, '0001_init.sql'), 'utf-8'),
19
20
  readFileSync(join(migrationsDir, '0002_auth.sql'), 'utf-8'),
21
+ readFileSync(join(migrationsDir, '0003_config.sql'), 'utf-8'),
20
22
  ];
21
23
  }
22
24
 
@@ -40,39 +42,6 @@ function jsonRpcResponse(id: number | string | null, result: unknown): string {
40
42
  return JSON.stringify({ jsonrpc: '2.0', id, result });
41
43
  }
42
44
 
43
- // Server instructions for Claude
44
- const SERVER_INSTRUCTIONS = `
45
- # Session Collaboration
46
-
47
- This MCP server coordinates multiple Claude Code sessions working on the same codebase.
48
-
49
- ## Required Behaviors
50
-
51
- 1. **On conversation start**: Immediately call \`collab_session_start\` with:
52
- - project_root: Current working directory
53
- - name: Descriptive session name (e.g., "feature-auth", "bugfix-api")
54
-
55
- 2. **Before editing any file**: Call \`collab_check\` with the file path to verify no conflicts
56
-
57
- 3. **If conflicts detected**:
58
- - Show warning to user
59
- - Ask if they want to proceed or coordinate
60
- - Consider using \`collab_message_send\` to notify other session
61
-
62
- 4. **For significant changes**: Call \`collab_claim\` before starting work on files
63
-
64
- 5. **When done with files**: Call \`collab_release\` to free them for others
65
-
66
- 6. **On conversation end**: Call \`collab_session_end\` to clean up
67
-
68
- ## Best Practices
69
-
70
- - Claim files early, release when done
71
- - Use descriptive intents when claiming (e.g., "Refactoring auth module")
72
- - Check for messages periodically with \`collab_message_list\`
73
- - Record architectural decisions with \`collab_decision_add\`
74
- `.trim();
75
-
76
45
  function jsonRpcError(id: number | string | null, code: number, message: string): string {
77
46
  return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
78
47
  }
@@ -118,8 +87,8 @@ async function main(): Promise<void> {
118
87
  tools: {},
119
88
  },
120
89
  serverInfo: {
121
- name: 'session-collab-mcp',
122
- version: '0.4.4',
90
+ name: SERVER_NAME,
91
+ version: VERSION,
123
92
  },
124
93
  instructions: SERVER_INSTRUCTIONS,
125
94
  });
@@ -0,0 +1,75 @@
1
+ // Shared constants for Session Collaboration MCP
2
+
3
+ import { readFileSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ // Read version from package.json
8
+ function getVersion(): string {
9
+ try {
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
12
+ return pkg.version;
13
+ } catch {
14
+ return '0.0.0';
15
+ }
16
+ }
17
+
18
+ export const VERSION = getVersion();
19
+
20
+ export const SERVER_NAME = 'session-collab-mcp';
21
+
22
+ export const SERVER_INSTRUCTIONS = `
23
+ # Session Collaboration
24
+
25
+ This MCP server coordinates multiple Claude Code sessions working on the same codebase.
26
+
27
+ ## Required Behaviors
28
+
29
+ 1. **On conversation start**: Immediately call \`collab_session_start\` with:
30
+ - project_root: Current working directory
31
+ - name: Descriptive session name (e.g., "feature-auth", "bugfix-api")
32
+
33
+ 2. **Before editing any file**: Call \`collab_check\` with the file path to verify no conflicts
34
+
35
+ 3. **If conflicts detected** (DEFAULT: coordinate):
36
+ - Show warning to user with options:
37
+ a) **Coordinate** (default): Send message to other session via \`collab_message_send\`, wait for response
38
+ b) **Bypass**: Proceed anyway (warn about potential conflicts)
39
+ c) **Request release**: Ask owner to release if claim seems stale
40
+ - NEVER auto-release another session's claim without explicit user permission
41
+
42
+ 4. **For significant changes**: Call \`collab_claim\` before starting work on files
43
+
44
+ 5. **When done with files**: Call \`collab_release\` with YOUR session_id to free them
45
+
46
+ 6. **On conversation end**: Call \`collab_session_end\` to clean up
47
+
48
+ ## Permission Rules
49
+
50
+ - You can ONLY release claims that belong to YOUR session
51
+ - To release another session's claim, you must ask the user and they must explicitly confirm
52
+ - Use \`force=true\` in \`collab_release\` only after user explicitly confirms
53
+ - When user chooses "coordinate", send a message first and suggest waiting
54
+
55
+ ## Conflict Handling Modes
56
+
57
+ Configure your session behavior with \`collab_config\`:
58
+
59
+ - **"strict"**: Always ask user, never bypass or auto-release
60
+ - **"smart"** (default): Ask user, but suggest auto-release for stale claims (>2hr old)
61
+ - **"bypass"**: Proceed despite conflicts (just warn, don't block)
62
+
63
+ Config options:
64
+ - \`mode\`: strict | smart | bypass
65
+ - \`allow_release_others\`: Allow releasing other sessions' claims (default: false)
66
+ - \`auto_release_stale\`: Auto-release stale claims (default: false)
67
+ - \`stale_threshold_hours\`: Hours before claim is stale (default: 2)
68
+
69
+ ## Best Practices
70
+
71
+ - Claim files early, release when done
72
+ - Use descriptive intents when claiming (e.g., "Refactoring auth module")
73
+ - Check for messages periodically with \`collab_message_list\`
74
+ - Record architectural decisions with \`collab_decision_add\`
75
+ `.trim();
@@ -1,13 +1,13 @@
1
1
  // Database queries for authentication
2
2
 
3
- import type { D1Database } from './sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from './sqlite-adapter.js';
4
4
  import type { User, UserPublic, ApiToken, ApiTokenPublic, RefreshToken } from './types';
5
5
  import { generateId, sha256 } from '../utils/crypto';
6
6
 
7
7
  // ============ User Queries ============
8
8
 
9
9
  export async function createUser(
10
- db: D1Database,
10
+ db: DatabaseAdapter,
11
11
  params: {
12
12
  email: string;
13
13
  password_hash: string;
@@ -37,29 +37,29 @@ export async function createUser(
37
37
  };
38
38
  }
39
39
 
40
- export async function getUserByEmail(db: D1Database, email: string): Promise<User | null> {
40
+ export async function getUserByEmail(db: DatabaseAdapter, email: string): Promise<User | null> {
41
41
  const result = await db.prepare('SELECT * FROM users WHERE email = ? AND status = ?').bind(email.toLowerCase(), 'active').first<User>();
42
42
  return result ?? null;
43
43
  }
44
44
 
45
- export async function getUserById(db: D1Database, id: string): Promise<User | null> {
45
+ export async function getUserById(db: DatabaseAdapter, id: string): Promise<User | null> {
46
46
  const result = await db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').bind(id, 'active').first<User>();
47
47
  return result ?? null;
48
48
  }
49
49
 
50
- export async function updateUserLastLogin(db: D1Database, id: string): Promise<void> {
50
+ export async function updateUserLastLogin(db: DatabaseAdapter, id: string): Promise<void> {
51
51
  const now = new Date().toISOString();
52
52
  await db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').bind(now, now, id).run();
53
53
  }
54
54
 
55
- export async function updateUserPassword(db: D1Database, id: string, password_hash: string): Promise<boolean> {
55
+ export async function updateUserPassword(db: DatabaseAdapter, id: string, password_hash: string): Promise<boolean> {
56
56
  const now = new Date().toISOString();
57
57
  const result = await db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').bind(password_hash, now, id).run();
58
58
  return result.meta.changes > 0;
59
59
  }
60
60
 
61
61
  export async function updateUserProfile(
62
- db: D1Database,
62
+ db: DatabaseAdapter,
63
63
  id: string,
64
64
  params: { display_name?: string }
65
65
  ): Promise<boolean> {
@@ -80,7 +80,7 @@ export function toUserPublic(user: User): UserPublic {
80
80
  // ============ API Token Queries ============
81
81
 
82
82
  export async function createApiToken(
83
- db: D1Database,
83
+ db: DatabaseAdapter,
84
84
  params: {
85
85
  user_id: string;
86
86
  name: string;
@@ -121,7 +121,7 @@ export async function createApiToken(
121
121
  };
122
122
  }
123
123
 
124
- export async function getApiTokenByHash(db: D1Database, tokenHash: string): Promise<ApiToken | null> {
124
+ export async function getApiTokenByHash(db: DatabaseAdapter, tokenHash: string): Promise<ApiToken | null> {
125
125
  const result = await db
126
126
  .prepare('SELECT * FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL')
127
127
  .bind(tokenHash)
@@ -137,7 +137,7 @@ export async function getApiTokenByHash(db: D1Database, tokenHash: string): Prom
137
137
  return result;
138
138
  }
139
139
 
140
- export async function listApiTokens(db: D1Database, userId: string): Promise<ApiTokenPublic[]> {
140
+ export async function listApiTokens(db: DatabaseAdapter, userId: string): Promise<ApiTokenPublic[]> {
141
141
  const result = await db
142
142
  .prepare('SELECT * FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC')
143
143
  .bind(userId)
@@ -146,13 +146,13 @@ export async function listApiTokens(db: D1Database, userId: string): Promise<Api
146
146
  return result.results.map(toApiTokenPublic);
147
147
  }
148
148
 
149
- export async function revokeApiToken(db: D1Database, id: string, userId: string): Promise<boolean> {
149
+ export async function revokeApiToken(db: DatabaseAdapter, id: string, userId: string): Promise<boolean> {
150
150
  const now = new Date().toISOString();
151
151
  const result = await db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?').bind(now, id, userId).run();
152
152
  return result.meta.changes > 0;
153
153
  }
154
154
 
155
- export async function updateApiTokenLastUsed(db: D1Database, id: string): Promise<void> {
155
+ export async function updateApiTokenLastUsed(db: DatabaseAdapter, id: string): Promise<void> {
156
156
  const now = new Date().toISOString();
157
157
  await db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').bind(now, id).run();
158
158
  }
@@ -172,7 +172,7 @@ export function toApiTokenPublic(token: ApiToken): ApiTokenPublic {
172
172
  // ============ Refresh Token Queries ============
173
173
 
174
174
  export async function createRefreshToken(
175
- db: D1Database,
175
+ db: DatabaseAdapter,
176
176
  params: {
177
177
  user_id: string;
178
178
  token_hash: string;
@@ -204,7 +204,7 @@ export async function createRefreshToken(
204
204
  };
205
205
  }
206
206
 
207
- export async function getRefreshTokenByHash(db: D1Database, tokenHash: string): Promise<RefreshToken | null> {
207
+ export async function getRefreshTokenByHash(db: DatabaseAdapter, tokenHash: string): Promise<RefreshToken | null> {
208
208
  const result = await db
209
209
  .prepare('SELECT * FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL')
210
210
  .bind(tokenHash)
@@ -220,13 +220,13 @@ export async function getRefreshTokenByHash(db: D1Database, tokenHash: string):
220
220
  return result;
221
221
  }
222
222
 
223
- export async function revokeRefreshToken(db: D1Database, id: string): Promise<boolean> {
223
+ export async function revokeRefreshToken(db: DatabaseAdapter, id: string): Promise<boolean> {
224
224
  const now = new Date().toISOString();
225
225
  const result = await db.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?').bind(now, id).run();
226
226
  return result.meta.changes > 0;
227
227
  }
228
228
 
229
- export async function revokeAllUserRefreshTokens(db: D1Database, userId: string): Promise<number> {
229
+ export async function revokeAllUserRefreshTokens(db: DatabaseAdapter, userId: string): Promise<number> {
230
230
  const now = new Date().toISOString();
231
231
  const result = await db.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').bind(now, userId).run();
232
232
  return result.meta.changes;
@@ -234,7 +234,7 @@ export async function revokeAllUserRefreshTokens(db: D1Database, userId: string)
234
234
 
235
235
  // ============ Cleanup Queries ============
236
236
 
237
- export async function cleanupExpiredTokens(db: D1Database): Promise<{ apiTokens: number; refreshTokens: number }> {
237
+ export async function cleanupExpiredTokens(db: DatabaseAdapter): Promise<{ apiTokens: number; refreshTokens: number }> {
238
238
  const now = new Date().toISOString();
239
239
 
240
240
  // Mark expired API tokens as revoked
package/src/db/queries.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Database queries for Session Collaboration MCP
2
2
 
3
- import type { D1Database } from './sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from './sqlite-adapter.js';
4
4
  import type {
5
5
  Session,
6
6
  Claim,
@@ -13,6 +13,7 @@ import type {
13
13
  DecisionCategory,
14
14
  TodoItem,
15
15
  SessionProgress,
16
+ SessionConfig,
16
17
  } from './types';
17
18
 
18
19
  // Helper to generate UUID v4
@@ -23,7 +24,7 @@ function generateId(): string {
23
24
  // ============ Session Queries ============
24
25
 
25
26
  export async function createSession(
26
- db: D1Database,
27
+ db: DatabaseAdapter,
27
28
  params: {
28
29
  name?: string;
29
30
  project_root: string;
@@ -54,16 +55,17 @@ export async function createSession(
54
55
  current_task: null,
55
56
  progress: null,
56
57
  todos: null,
58
+ config: null,
57
59
  };
58
60
  }
59
61
 
60
- export async function getSession(db: D1Database, id: string): Promise<Session | null> {
62
+ export async function getSession(db: DatabaseAdapter, id: string): Promise<Session | null> {
61
63
  const result = await db.prepare('SELECT * FROM sessions WHERE id = ?').bind(id).first<Session>();
62
64
  return result ?? null;
63
65
  }
64
66
 
65
67
  export async function listSessions(
66
- db: D1Database,
68
+ db: DatabaseAdapter,
67
69
  params: {
68
70
  include_inactive?: boolean;
69
71
  project_root?: string;
@@ -97,7 +99,7 @@ export async function listSessions(
97
99
  }
98
100
 
99
101
  export async function updateSessionHeartbeat(
100
- db: D1Database,
102
+ db: DatabaseAdapter,
101
103
  id: string,
102
104
  statusUpdate?: {
103
105
  current_task?: string | null;
@@ -150,7 +152,7 @@ export async function updateSessionHeartbeat(
150
152
  }
151
153
 
152
154
  export async function updateSessionStatus(
153
- db: D1Database,
155
+ db: DatabaseAdapter,
154
156
  id: string,
155
157
  params: {
156
158
  current_task?: string | null;
@@ -193,7 +195,7 @@ export async function updateSessionStatus(
193
195
  }
194
196
 
195
197
  export async function endSession(
196
- db: D1Database,
198
+ db: DatabaseAdapter,
197
199
  id: string,
198
200
  release_claims: 'complete' | 'abandon' = 'abandon'
199
201
  ): Promise<boolean> {
@@ -211,7 +213,7 @@ export async function endSession(
211
213
  return result.meta.changes > 0;
212
214
  }
213
215
 
214
- export async function cleanupStaleSessions(db: D1Database, staleMinutes: number = 30): Promise<{ stale_sessions: number; orphaned_claims: number }> {
216
+ export async function cleanupStaleSessions(db: DatabaseAdapter, staleMinutes: number = 30): Promise<{ stale_sessions: number; orphaned_claims: number }> {
215
217
  const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
216
218
  const now = new Date().toISOString();
217
219
 
@@ -238,10 +240,23 @@ export async function cleanupStaleSessions(db: D1Database, staleMinutes: number
238
240
  };
239
241
  }
240
242
 
243
+ export async function updateSessionConfig(
244
+ db: DatabaseAdapter,
245
+ id: string,
246
+ config: SessionConfig
247
+ ): Promise<boolean> {
248
+ const result = await db
249
+ .prepare('UPDATE sessions SET config = ? WHERE id = ?')
250
+ .bind(JSON.stringify(config), id)
251
+ .run();
252
+
253
+ return result.meta.changes > 0;
254
+ }
255
+
241
256
  // ============ Claim Queries ============
242
257
 
243
258
  export async function createClaim(
244
- db: D1Database,
259
+ db: DatabaseAdapter,
245
260
  params: {
246
261
  session_id: string;
247
262
  files: string[];
@@ -285,7 +300,7 @@ export async function createClaim(
285
300
  };
286
301
  }
287
302
 
288
- export async function getClaim(db: D1Database, id: string): Promise<ClaimWithFiles | null> {
303
+ export async function getClaim(db: DatabaseAdapter, id: string): Promise<ClaimWithFiles | null> {
289
304
  const claim = await db.prepare('SELECT * FROM claims WHERE id = ?').bind(id).first<Claim>();
290
305
 
291
306
  if (!claim) return null;
@@ -302,7 +317,7 @@ export async function getClaim(db: D1Database, id: string): Promise<ClaimWithFil
302
317
  }
303
318
 
304
319
  export async function listClaims(
305
- db: D1Database,
320
+ db: DatabaseAdapter,
306
321
  params: {
307
322
  session_id?: string;
308
323
  status?: ClaimStatus | 'all';
@@ -367,7 +382,7 @@ export async function listClaims(
367
382
  }
368
383
 
369
384
  export async function checkConflicts(
370
- db: D1Database,
385
+ db: DatabaseAdapter,
371
386
  files: string[],
372
387
  excludeSessionId?: string
373
388
  ): Promise<ConflictInfo[]> {
@@ -417,7 +432,7 @@ export async function checkConflicts(
417
432
  }
418
433
 
419
434
  export async function releaseClaim(
420
- db: D1Database,
435
+ db: DatabaseAdapter,
421
436
  id: string,
422
437
  params: {
423
438
  status: 'completed' | 'abandoned';
@@ -437,7 +452,7 @@ export async function releaseClaim(
437
452
  // ============ Message Queries ============
438
453
 
439
454
  export async function sendMessage(
440
- db: D1Database,
455
+ db: DatabaseAdapter,
441
456
  params: {
442
457
  from_session_id: string;
443
458
  to_session_id?: string;
@@ -466,7 +481,7 @@ export async function sendMessage(
466
481
  }
467
482
 
468
483
  export async function listMessages(
469
- db: D1Database,
484
+ db: DatabaseAdapter,
470
485
  params: {
471
486
  session_id: string;
472
487
  unread_only?: boolean;
@@ -506,7 +521,7 @@ export async function listMessages(
506
521
  // ============ Decision Queries ============
507
522
 
508
523
  export async function addDecision(
509
- db: D1Database,
524
+ db: DatabaseAdapter,
510
525
  params: {
511
526
  session_id: string;
512
527
  category?: DecisionCategory;
@@ -536,7 +551,7 @@ export async function addDecision(
536
551
  }
537
552
 
538
553
  export async function listDecisions(
539
- db: D1Database,
554
+ db: DatabaseAdapter,
540
555
  params: {
541
556
  category?: DecisionCategory;
542
557
  limit?: number;
@@ -1,5 +1,5 @@
1
- // SQLite adapter: wraps better-sqlite3 to match D1-like API
2
- // This allows the same queries.ts to work with both D1 and local SQLite
1
+ // SQLite adapter: wraps better-sqlite3 with a generic database interface
2
+ // This allows the same queries.ts to work with both Cloudflare D1 and local SQLite
3
3
 
4
4
  import Database from 'better-sqlite3';
5
5
  import { randomUUID } from 'crypto';
@@ -14,7 +14,7 @@ if (typeof globalThis.crypto === 'undefined') {
14
14
  };
15
15
  }
16
16
 
17
- export interface D1Result<T> {
17
+ export interface QueryResult<T> {
18
18
  results: T[];
19
19
  meta: {
20
20
  changes: number;
@@ -22,27 +22,28 @@ export interface D1Result<T> {
22
22
  };
23
23
  }
24
24
 
25
- export interface D1PreparedStatement {
26
- bind(...values: unknown[]): D1PreparedStatement;
25
+ export interface PreparedStatement {
26
+ bind(...values: unknown[]): PreparedStatement;
27
27
  first<T>(): Promise<T | null>;
28
- all<T>(): Promise<D1Result<T>>;
28
+ all<T>(): Promise<QueryResult<T>>;
29
29
  run(): Promise<{ meta: { changes: number } }>;
30
30
  }
31
31
 
32
- export interface D1Database {
33
- prepare(sql: string): D1PreparedStatement;
34
- batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]>;
32
+ export interface DatabaseAdapter {
33
+ prepare(sql: string): PreparedStatement;
34
+ batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]>;
35
35
  }
36
36
 
37
- class SqlitePreparedStatement implements D1PreparedStatement {
37
+ class SqlitePreparedStatement implements PreparedStatement {
38
38
  private bindings: unknown[] = [];
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
- bind(...values: unknown[]): D1PreparedStatement {
46
+ bind(...values: unknown[]): PreparedStatement {
46
47
  this.bindings = values;
47
48
  return this;
48
49
  }
@@ -53,7 +54,7 @@ class SqlitePreparedStatement implements D1PreparedStatement {
53
54
  return result ?? null;
54
55
  }
55
56
 
56
- async all<T>(): Promise<D1Result<T>> {
57
+ async all<T>(): Promise<QueryResult<T>> {
57
58
  const stmt = this.db.prepare(this.sql);
58
59
  const results = stmt.all(...this.bindings) as T[];
59
60
  return {
@@ -65,6 +66,8 @@ class SqlitePreparedStatement implements D1PreparedStatement {
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
  };
@@ -77,7 +80,7 @@ class SqlitePreparedStatement implements D1PreparedStatement {
77
80
  }
78
81
  }
79
82
 
80
- class SqliteDatabase implements D1Database {
83
+ class SqliteDatabase implements DatabaseAdapter {
81
84
  private db: Database.Database;
82
85
 
83
86
  constructor(dbPath: string) {
@@ -88,15 +91,28 @@ class SqliteDatabase implements D1Database {
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
- prepare(sql: string): D1PreparedStatement {
96
- return new SqlitePreparedStatement(this.db, sql);
111
+ prepare(sql: string): PreparedStatement {
112
+ return new SqlitePreparedStatement(this.db, sql, () => this.checkpoint());
97
113
  }
98
114
 
99
- async batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]> {
115
+ async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
100
116
  const transaction = this.db.transaction(() => {
101
117
  return statements.map((stmt) => {
102
118
  const sqliteStmt = stmt as SqlitePreparedStatement;
@@ -107,7 +123,10 @@ class SqliteDatabase implements D1Database {
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
@@ -64,6 +64,23 @@ export interface TodoItem {
64
64
  status: 'pending' | 'in_progress' | 'completed';
65
65
  }
66
66
 
67
+ // Session configuration
68
+ export type ConflictMode = 'strict' | 'smart' | 'bypass';
69
+
70
+ export interface SessionConfig {
71
+ mode: ConflictMode;
72
+ allow_release_others: boolean;
73
+ auto_release_stale: boolean;
74
+ stale_threshold_hours: number;
75
+ }
76
+
77
+ export const DEFAULT_SESSION_CONFIG: SessionConfig = {
78
+ mode: 'smart',
79
+ allow_release_others: false,
80
+ auto_release_stale: false,
81
+ stale_threshold_hours: 2,
82
+ };
83
+
67
84
  export interface SessionProgress {
68
85
  completed: number;
69
86
  total: number;
@@ -82,6 +99,7 @@ export interface Session {
82
99
  current_task: string | null;
83
100
  progress: string | null; // JSON string of SessionProgress
84
101
  todos: string | null; // JSON string of TodoItem[]
102
+ config: string | null; // JSON string of SessionConfig
85
103
  }
86
104
 
87
105
  export interface Claim {
package/src/mcp/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // MCP Server implementation for Session Collaboration
2
+ // Last edited: 2025-12-29 by session-b
2
3
 
3
- import type { D1Database } from '../db/sqlite-adapter.js';
4
+ import type { DatabaseAdapter } from '../db/sqlite-adapter.js';
4
5
  import {
5
6
  JsonRpcRequestSchema,
6
7
  type JsonRpcRequest,
@@ -19,55 +20,24 @@ import { claimTools, handleClaimTool } from './tools/claim';
19
20
  import { messageTools, handleMessageTool } from './tools/message';
20
21
  import { decisionTools, handleDecisionTool } from './tools/decision';
21
22
  import type { AuthContext } from '../auth/types';
23
+ import { VERSION, SERVER_NAME, SERVER_INSTRUCTIONS } from '../constants.js';
22
24
 
23
25
  const SERVER_INFO: McpServerInfo = {
24
- name: 'session-collab-mcp',
25
- version: '0.3.0',
26
+ name: SERVER_NAME,
27
+ version: VERSION,
26
28
  };
27
29
 
28
30
  const CAPABILITIES: McpCapabilities = {
29
31
  tools: {},
30
32
  };
31
33
 
32
- const SERVER_INSTRUCTIONS = `
33
- # Session Collaboration
34
-
35
- This MCP server coordinates multiple Claude Code sessions working on the same codebase.
36
-
37
- ## Required Behaviors
38
-
39
- 1. **On conversation start**: Immediately call \`collab_session_start\` with:
40
- - project_root: Current working directory
41
- - name: Descriptive session name (e.g., "feature-auth", "bugfix-api")
42
-
43
- 2. **Before editing any file**: Call \`collab_check\` with the file path to verify no conflicts
44
-
45
- 3. **If conflicts detected**:
46
- - Show warning to user
47
- - Ask if they want to proceed or coordinate
48
- - Consider using \`collab_message_send\` to notify other session
49
-
50
- 4. **For significant changes**: Call \`collab_claim\` before starting work on files
51
-
52
- 5. **When done with files**: Call \`collab_release\` to free them for others
53
-
54
- 6. **On conversation end**: Call \`collab_session_end\` to clean up
55
-
56
- ## Best Practices
57
-
58
- - Claim files early, release when done
59
- - Use descriptive intents when claiming (e.g., "Refactoring auth module")
60
- - Check for messages periodically with \`collab_message_list\`
61
- - Record architectural decisions with \`collab_decision_add\`
62
- `.trim();
63
-
64
34
  // Combine all tools
65
35
  const ALL_TOOLS: McpTool[] = [...sessionTools, ...claimTools, ...messageTools, ...decisionTools];
66
36
 
67
37
  export class McpServer {
68
38
  private authContext?: AuthContext;
69
39
 
70
- constructor(private db: D1Database, authContext?: AuthContext) {
40
+ constructor(private db: DatabaseAdapter, authContext?: AuthContext) {
71
41
  this.authContext = authContext;
72
42
  }
73
43
 
@@ -127,7 +97,7 @@ export class McpServer {
127
97
 
128
98
  try {
129
99
  // Route to appropriate handler
130
- if (name.startsWith('collab_session_') || name === 'collab_status_update') {
100
+ if (name.startsWith('collab_session_') || name === 'collab_status_update' || name === 'collab_config') {
131
101
  result = await handleSessionTool(this.db, name, args, userId);
132
102
  } else if (name.startsWith('collab_claim') || name === 'collab_check' || name === 'collab_release') {
133
103
  result = await handleClaimTool(this.db, name, args);
@@ -165,12 +135,12 @@ export function getMcpTools(): McpTool[] {
165
135
 
166
136
  // Handle MCP tool call for CLI
167
137
  export async function handleMcpRequest(
168
- db: D1Database,
138
+ db: DatabaseAdapter,
169
139
  name: string,
170
140
  args: Record<string, unknown>
171
141
  ): Promise<McpToolResult> {
172
142
  try {
173
- if (name.startsWith('collab_session_') || name === 'collab_status_update') {
143
+ if (name.startsWith('collab_session_') || name === 'collab_status_update' || name === 'collab_config') {
174
144
  return await handleSessionTool(db, name, args);
175
145
  } else if (name.startsWith('collab_claim') || name === 'collab_check' || name === 'collab_release') {
176
146
  return await handleClaimTool(db, name, args);
@@ -1,9 +1,10 @@
1
1
  // Claim management tools (WIP declarations)
2
2
 
3
- import type { D1Database } from '../../db/sqlite-adapter.js';
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 } from '../../db/types';
6
+ import type { ClaimScope, SessionConfig } from '../../db/types';
7
+ import { DEFAULT_SESSION_CONFIG } from '../../db/types';
7
8
  import { createClaim, getClaim, listClaims, checkConflicts, releaseClaim, getSession } from '../../db/queries';
8
9
 
9
10
  export const claimTools: McpTool[] = [
@@ -58,10 +59,14 @@ export const claimTools: McpTool[] = [
58
59
  },
59
60
  {
60
61
  name: 'collab_release',
61
- description: 'Release a claim when done or abandoning work.',
62
+ description: 'Release a claim when done or abandoning work. By default you can only release your own claims. Use force=true with user confirmation to release stale claims from other sessions.',
62
63
  inputSchema: {
63
64
  type: 'object',
64
65
  properties: {
66
+ session_id: {
67
+ type: 'string',
68
+ description: 'Your session ID (required to verify ownership)',
69
+ },
65
70
  claim_id: {
66
71
  type: 'string',
67
72
  description: 'Claim ID to release',
@@ -75,8 +80,12 @@ export const claimTools: McpTool[] = [
75
80
  type: 'string',
76
81
  description: 'Optional summary of what was done (for completed claims)',
77
82
  },
83
+ force: {
84
+ type: 'boolean',
85
+ description: 'Force release even if claim belongs to another session (requires user confirmation)',
86
+ },
78
87
  },
79
- required: ['claim_id', 'status'],
88
+ required: ['session_id', 'claim_id', 'status'],
80
89
  },
81
90
  },
82
91
  {
@@ -104,7 +113,7 @@ export const claimTools: McpTool[] = [
104
113
  ];
105
114
 
106
115
  export async function handleClaimTool(
107
- db: D1Database,
116
+ db: DatabaseAdapter,
108
117
  name: string,
109
118
  args: Record<string, unknown>
110
119
  ): Promise<McpToolResult> {
@@ -286,9 +295,22 @@ export async function handleClaimTool(
286
295
  }
287
296
 
288
297
  case 'collab_release': {
298
+ const sessionId = args.session_id as string;
289
299
  const claimId = args.claim_id as string;
290
300
  const status = args.status as 'completed' | 'abandoned';
291
301
  const summary = args.summary as string | undefined;
302
+ const force = args.force as boolean | undefined;
303
+
304
+ // Validate session_id
305
+ if (!sessionId || typeof sessionId !== 'string') {
306
+ return createToolResult(
307
+ JSON.stringify({
308
+ error: 'INVALID_INPUT',
309
+ message: 'session_id is required to verify ownership',
310
+ }),
311
+ true
312
+ );
313
+ }
292
314
 
293
315
  const claim = await getClaim(db, claimId);
294
316
  if (!claim) {
@@ -301,6 +323,46 @@ export async function handleClaimTool(
301
323
  );
302
324
  }
303
325
 
326
+ // Check ownership - only allow releasing your own claims unless config allows or force=true
327
+ if (claim.session_id !== sessionId) {
328
+ // Get caller's session config
329
+ const callerSession = await getSession(db, sessionId);
330
+ let config: SessionConfig = DEFAULT_SESSION_CONFIG;
331
+ if (callerSession?.config) {
332
+ try {
333
+ config = { ...DEFAULT_SESSION_CONFIG, ...JSON.parse(callerSession.config) };
334
+ } catch {
335
+ // Use default if parse fails
336
+ }
337
+ }
338
+
339
+ // Check if allowed to release others' claims
340
+ const canRelease = force === true || config.allow_release_others;
341
+
342
+ if (!canRelease) {
343
+ // Calculate how old the claim is
344
+ const claimAge = Date.now() - new Date(claim.created_at).getTime();
345
+ const staleHours = config.stale_threshold_hours;
346
+ const isStale = claimAge > staleHours * 60 * 60 * 1000;
347
+
348
+ return createToolResult(
349
+ JSON.stringify({
350
+ error: 'NOT_OWNER',
351
+ message: 'You can only release your own claims. This claim belongs to another session.',
352
+ claim_owner: claim.session_name,
353
+ claim_age_hours: Math.round(claimAge / (60 * 60 * 1000) * 10) / 10,
354
+ is_stale: isStale,
355
+ suggestions: [
356
+ 'Use collab_message_send to ask the owner to release it.',
357
+ isStale ? 'This claim is stale. Ask user for confirmation, then use force=true to release.' : null,
358
+ 'Use collab_config to enable allow_release_others for future releases.',
359
+ ].filter(Boolean),
360
+ }),
361
+ true
362
+ );
363
+ }
364
+ }
365
+
304
366
  if (claim.status !== 'active') {
305
367
  return createToolResult(
306
368
  JSON.stringify({
@@ -311,14 +373,18 @@ export async function handleClaimTool(
311
373
  );
312
374
  }
313
375
 
376
+ const isOwnClaim = claim.session_id === sessionId;
314
377
  await releaseClaim(db, claimId, { status, summary });
315
378
 
316
379
  return createToolResult(
317
380
  JSON.stringify({
318
381
  success: true,
319
- message: `Claim ${status}. Files are now available for other sessions.`,
382
+ message: isOwnClaim
383
+ ? `Claim ${status}. Files are now available for other sessions.`
384
+ : `Claim from ${claim.session_name} forcefully ${status}.`,
320
385
  files: claim.files,
321
386
  summary: summary ?? null,
387
+ was_forced: !isOwnClaim,
322
388
  })
323
389
  );
324
390
  }
@@ -1,6 +1,6 @@
1
1
  // Decision recording tools
2
2
 
3
- import type { D1Database } from '../../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
4
  import type { McpTool, McpToolResult } from '../protocol';
5
5
  import { createToolResult } from '../protocol';
6
6
  import type { DecisionCategory } from '../../db/types';
@@ -55,7 +55,7 @@ export const decisionTools: McpTool[] = [
55
55
  ];
56
56
 
57
57
  export async function handleDecisionTool(
58
- db: D1Database,
58
+ db: DatabaseAdapter,
59
59
  name: string,
60
60
  args: Record<string, unknown>
61
61
  ): Promise<McpToolResult> {
@@ -1,6 +1,6 @@
1
1
  // Inter-session messaging tools
2
2
 
3
- import type { D1Database } from '../../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
4
  import type { McpTool, McpToolResult } from '../protocol';
5
5
  import { createToolResult } from '../protocol';
6
6
  import { sendMessage, listMessages, getSession } from '../../db/queries';
@@ -53,7 +53,7 @@ export const messageTools: McpTool[] = [
53
53
  ];
54
54
 
55
55
  export async function handleMessageTool(
56
- db: D1Database,
56
+ db: DatabaseAdapter,
57
57
  name: string,
58
58
  args: Record<string, unknown>
59
59
  ): Promise<McpToolResult> {
@@ -1,6 +1,6 @@
1
1
  // Session management tools
2
2
 
3
- import type { D1Database } from '../../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
4
  import type { McpTool, McpToolResult } from '../protocol';
5
5
  import { createToolResult } from '../protocol';
6
6
  import {
@@ -9,11 +9,13 @@ import {
9
9
  listSessions,
10
10
  updateSessionHeartbeat,
11
11
  updateSessionStatus,
12
+ updateSessionConfig,
12
13
  endSession,
13
14
  cleanupStaleSessions,
14
15
  listClaims,
15
16
  } from '../../db/queries';
16
- import type { TodoItem } from '../../db/types';
17
+ import type { TodoItem, SessionConfig, ConflictMode } from '../../db/types';
18
+ import { DEFAULT_SESSION_CONFIG } from '../../db/types';
17
19
 
18
20
  export const sessionTools: McpTool[] = [
19
21
  {
@@ -134,10 +136,41 @@ export const sessionTools: McpTool[] = [
134
136
  required: ['session_id'],
135
137
  },
136
138
  },
139
+ {
140
+ name: 'collab_config',
141
+ description: 'Configure session behavior for conflict handling. Settings persist for the session duration.',
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: {
145
+ session_id: {
146
+ type: 'string',
147
+ description: 'Your session ID',
148
+ },
149
+ mode: {
150
+ type: 'string',
151
+ enum: ['strict', 'smart', 'bypass'],
152
+ description: 'Conflict handling mode: strict (always ask), smart (ask but suggest for stale), bypass (warn only)',
153
+ },
154
+ allow_release_others: {
155
+ type: 'boolean',
156
+ description: 'Allow releasing claims from other sessions (default: false)',
157
+ },
158
+ auto_release_stale: {
159
+ type: 'boolean',
160
+ description: 'Automatically release stale claims (default: false)',
161
+ },
162
+ stale_threshold_hours: {
163
+ type: 'number',
164
+ description: 'Hours before a claim is considered stale (default: 2)',
165
+ },
166
+ },
167
+ required: ['session_id'],
168
+ },
169
+ },
137
170
  ];
138
171
 
139
172
  export async function handleSessionTool(
140
- db: D1Database,
173
+ db: DatabaseAdapter,
141
174
  name: string,
142
175
  args: Record<string, unknown>,
143
176
  userId?: string
@@ -316,6 +349,65 @@ export async function handleSessionTool(
316
349
  );
317
350
  }
318
351
 
352
+ case 'collab_config': {
353
+ const sessionId = args.session_id as string;
354
+
355
+ // Validate session_id input
356
+ if (!sessionId || typeof sessionId !== 'string') {
357
+ return createToolResult(
358
+ JSON.stringify({
359
+ error: 'INVALID_INPUT',
360
+ message: 'session_id is required',
361
+ }),
362
+ true
363
+ );
364
+ }
365
+
366
+ // Validate session exists
367
+ const session = await getSession(db, sessionId);
368
+ if (!session || session.status !== 'active') {
369
+ return createToolResult(
370
+ JSON.stringify({
371
+ error: 'SESSION_INVALID',
372
+ message: 'Session not found or inactive.',
373
+ }),
374
+ true
375
+ );
376
+ }
377
+
378
+ // Get current config or default
379
+ let currentConfig: SessionConfig = DEFAULT_SESSION_CONFIG;
380
+ if (session.config) {
381
+ try {
382
+ currentConfig = { ...DEFAULT_SESSION_CONFIG, ...JSON.parse(session.config) };
383
+ } catch {
384
+ // Use default if parse fails
385
+ }
386
+ }
387
+
388
+ // Update with new values
389
+ const newConfig: SessionConfig = {
390
+ mode: (args.mode as ConflictMode) ?? currentConfig.mode,
391
+ allow_release_others: args.allow_release_others !== undefined
392
+ ? (args.allow_release_others as boolean)
393
+ : currentConfig.allow_release_others,
394
+ auto_release_stale: args.auto_release_stale !== undefined
395
+ ? (args.auto_release_stale as boolean)
396
+ : currentConfig.auto_release_stale,
397
+ stale_threshold_hours: (args.stale_threshold_hours as number) ?? currentConfig.stale_threshold_hours,
398
+ };
399
+
400
+ await updateSessionConfig(db, sessionId, newConfig);
401
+
402
+ return createToolResult(
403
+ JSON.stringify({
404
+ success: true,
405
+ message: 'Configuration updated.',
406
+ config: newConfig,
407
+ })
408
+ );
409
+ }
410
+
319
411
  default:
320
412
  return createToolResult(`Unknown session tool: ${name}`, true);
321
413
  }
@@ -1,6 +1,6 @@
1
1
  // Token management API handlers
2
2
 
3
- import type { D1Database } from '../db/sqlite-adapter.js';
3
+ import type { DatabaseAdapter } from '../db/sqlite-adapter.js';
4
4
  import { z } from 'zod';
5
5
  import { generateApiToken } from './generator';
6
6
  import { createApiToken, listApiTokens, revokeApiToken } from '../db/auth-queries';
@@ -14,7 +14,7 @@ const CreateTokenRequestSchema = z.object({
14
14
  });
15
15
 
16
16
  interface HandlerContext {
17
- db: D1Database;
17
+ db: DatabaseAdapter;
18
18
  request: Request;
19
19
  }
20
20