session-collab-mcp 0.5.0 → 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.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/migrations/0006_composite_indexes.sql +14 -0
- package/package.json +10 -1
- package/src/cli.ts +1 -0
- package/src/db/__tests__/queries.test.ts +799 -0
- package/src/db/__tests__/test-helper.ts +216 -0
- package/src/db/queries.ts +27 -9
- package/src/db/sqlite-adapter.ts +6 -6
- package/src/mcp/schemas.ts +200 -0
- package/src/mcp/tools/claim.ts +38 -59
- package/src/mcp/tools/decision.ts +26 -13
- package/src/mcp/tools/lsp.ts +36 -55
- package/src/mcp/tools/message.ts +28 -14
- package/src/mcp/tools/session.ts +82 -42
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Test helper: In-memory SQLite database for testing
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import type { DatabaseAdapter, PreparedStatement, QueryResult } from '../sqlite-adapter.js';
|
|
4
|
+
|
|
5
|
+
class TestPreparedStatement implements PreparedStatement {
|
|
6
|
+
private bindings: unknown[] = [];
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private db: Database.Database,
|
|
10
|
+
private sql: string
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
bind(...values: unknown[]): PreparedStatement {
|
|
14
|
+
this.bindings = values;
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async first<T>(): Promise<T | null> {
|
|
19
|
+
const stmt = this.db.prepare(this.sql);
|
|
20
|
+
const result = stmt.get(...this.bindings) as T | undefined;
|
|
21
|
+
return result ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async all<T>(): Promise<QueryResult<T>> {
|
|
25
|
+
const stmt = this.db.prepare(this.sql);
|
|
26
|
+
const results = stmt.all(...this.bindings) as T[];
|
|
27
|
+
return {
|
|
28
|
+
results,
|
|
29
|
+
meta: { changes: 0, last_row_id: 0 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async run(): Promise<{ meta: { changes: number } }> {
|
|
34
|
+
const stmt = this.db.prepare(this.sql);
|
|
35
|
+
const result = stmt.run(...this.bindings);
|
|
36
|
+
return {
|
|
37
|
+
meta: { changes: result.changes },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_run(): Database.RunResult {
|
|
42
|
+
const stmt = this.db.prepare(this.sql);
|
|
43
|
+
return stmt.run(...this.bindings);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Schema statements split for initialization
|
|
48
|
+
const SCHEMA_STATEMENTS = [
|
|
49
|
+
// Sessions table
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
name TEXT,
|
|
53
|
+
project_root TEXT NOT NULL,
|
|
54
|
+
machine_id TEXT,
|
|
55
|
+
user_id TEXT,
|
|
56
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
57
|
+
last_heartbeat TEXT DEFAULT (datetime('now')),
|
|
58
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'terminated')),
|
|
59
|
+
current_task TEXT,
|
|
60
|
+
progress TEXT,
|
|
61
|
+
todos TEXT,
|
|
62
|
+
config TEXT
|
|
63
|
+
)`,
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status)`,
|
|
65
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root)`,
|
|
66
|
+
|
|
67
|
+
// Claims table
|
|
68
|
+
`CREATE TABLE IF NOT EXISTS claims (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
session_id TEXT NOT NULL,
|
|
71
|
+
intent TEXT NOT NULL,
|
|
72
|
+
scope TEXT DEFAULT 'medium' CHECK (scope IN ('small', 'medium', 'large')),
|
|
73
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
|
|
74
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
75
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
76
|
+
completed_summary TEXT,
|
|
77
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
78
|
+
)`,
|
|
79
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id)`,
|
|
80
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status)`,
|
|
81
|
+
|
|
82
|
+
// Claim files
|
|
83
|
+
`CREATE TABLE IF NOT EXISTS claim_files (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
claim_id TEXT NOT NULL,
|
|
86
|
+
file_path TEXT NOT NULL,
|
|
87
|
+
is_pattern INTEGER DEFAULT 0,
|
|
88
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
89
|
+
UNIQUE(claim_id, file_path)
|
|
90
|
+
)`,
|
|
91
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_path ON claim_files(file_path)`,
|
|
92
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_claim ON claim_files(claim_id)`,
|
|
93
|
+
|
|
94
|
+
// Claim symbols
|
|
95
|
+
`CREATE TABLE IF NOT EXISTS claim_symbols (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
claim_id TEXT NOT NULL,
|
|
98
|
+
file_path TEXT NOT NULL,
|
|
99
|
+
symbol_name TEXT NOT NULL,
|
|
100
|
+
symbol_type TEXT DEFAULT 'function' CHECK (symbol_type IN ('function', 'class', 'method', 'variable', 'block', 'other')),
|
|
101
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
102
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
103
|
+
UNIQUE(claim_id, file_path, symbol_name)
|
|
104
|
+
)`,
|
|
105
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_path ON claim_symbols(file_path)`,
|
|
106
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_name ON claim_symbols(symbol_name)`,
|
|
107
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_claim ON claim_symbols(claim_id)`,
|
|
108
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_lookup ON claim_symbols(file_path, symbol_name)`,
|
|
109
|
+
|
|
110
|
+
// Messages table
|
|
111
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
112
|
+
id TEXT PRIMARY KEY,
|
|
113
|
+
from_session_id TEXT NOT NULL,
|
|
114
|
+
to_session_id TEXT,
|
|
115
|
+
content TEXT NOT NULL,
|
|
116
|
+
read_at TEXT,
|
|
117
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
118
|
+
FOREIGN KEY (from_session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
119
|
+
)`,
|
|
120
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session_id)`,
|
|
121
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_session_id, read_at)`,
|
|
122
|
+
|
|
123
|
+
// Decisions table
|
|
124
|
+
`CREATE TABLE IF NOT EXISTS decisions (
|
|
125
|
+
id TEXT PRIMARY KEY,
|
|
126
|
+
session_id TEXT NOT NULL,
|
|
127
|
+
category TEXT CHECK (category IN ('architecture', 'naming', 'api', 'database', 'ui', 'other')),
|
|
128
|
+
title TEXT NOT NULL,
|
|
129
|
+
description TEXT NOT NULL,
|
|
130
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
131
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
132
|
+
)`,
|
|
133
|
+
`CREATE INDEX IF NOT EXISTS idx_decisions_category ON decisions(category)`,
|
|
134
|
+
|
|
135
|
+
// Symbol references
|
|
136
|
+
`CREATE TABLE IF NOT EXISTS symbol_references (
|
|
137
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
138
|
+
source_file TEXT NOT NULL,
|
|
139
|
+
source_symbol TEXT NOT NULL,
|
|
140
|
+
ref_file TEXT NOT NULL,
|
|
141
|
+
ref_line INTEGER,
|
|
142
|
+
ref_context TEXT,
|
|
143
|
+
session_id TEXT NOT NULL,
|
|
144
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
145
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
146
|
+
UNIQUE(source_file, source_symbol, ref_file, ref_line)
|
|
147
|
+
)`,
|
|
148
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_source ON symbol_references(source_file, source_symbol)`,
|
|
149
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_ref_file ON symbol_references(ref_file)`,
|
|
150
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_session ON symbol_references(session_id)`,
|
|
151
|
+
|
|
152
|
+
// Composite indexes for common query patterns
|
|
153
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_status_heartbeat ON sessions(status, last_heartbeat)`,
|
|
154
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_status_session ON claims(status, session_id)`,
|
|
155
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_path_claim ON claim_files(file_path, claim_id)`,
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const CLEANUP_STATEMENTS = [
|
|
159
|
+
'DELETE FROM symbol_references',
|
|
160
|
+
'DELETE FROM claim_symbols',
|
|
161
|
+
'DELETE FROM claim_files',
|
|
162
|
+
'DELETE FROM claims',
|
|
163
|
+
'DELETE FROM messages',
|
|
164
|
+
'DELETE FROM decisions',
|
|
165
|
+
'DELETE FROM sessions',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
export class TestDatabase implements DatabaseAdapter {
|
|
169
|
+
private db: Database.Database;
|
|
170
|
+
|
|
171
|
+
constructor() {
|
|
172
|
+
// Use in-memory database for tests
|
|
173
|
+
this.db = new Database(':memory:');
|
|
174
|
+
this.db.pragma('foreign_keys = ON');
|
|
175
|
+
this.initSchema();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private initSchema(): void {
|
|
179
|
+
for (const sql of SCHEMA_STATEMENTS) {
|
|
180
|
+
this.db.prepare(sql).run();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
prepare(sql: string): PreparedStatement {
|
|
185
|
+
return new TestPreparedStatement(this.db, sql);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
|
|
189
|
+
const transaction = this.db.transaction(() => {
|
|
190
|
+
return statements.map((stmt) => {
|
|
191
|
+
const testStmt = stmt as TestPreparedStatement;
|
|
192
|
+
const result = testStmt._run();
|
|
193
|
+
return {
|
|
194
|
+
results: [],
|
|
195
|
+
meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) },
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
return transaction();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
close(): void {
|
|
203
|
+
this.db.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Helper to reset database between tests
|
|
207
|
+
reset(): void {
|
|
208
|
+
for (const sql of CLEANUP_STATEMENTS) {
|
|
209
|
+
this.db.prepare(sql).run();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createTestDatabase(): TestDatabase {
|
|
215
|
+
return new TestDatabase();
|
|
216
|
+
}
|
package/src/db/queries.ts
CHANGED
|
@@ -321,18 +321,36 @@ export async function createClaim(
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
export async function getClaim(db: DatabaseAdapter, id: string): Promise<ClaimWithFiles | null> {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
324
|
+
// Single query with JOIN to avoid N+1 problem
|
|
325
|
+
const result = await db
|
|
326
|
+
.prepare(
|
|
327
|
+
`SELECT
|
|
328
|
+
c.id, c.session_id, c.intent, c.scope, c.status,
|
|
329
|
+
c.created_at, c.updated_at, c.completed_summary,
|
|
330
|
+
s.name as session_name,
|
|
331
|
+
GROUP_CONCAT(cf.file_path, '|||') as file_paths
|
|
332
|
+
FROM claims c
|
|
333
|
+
LEFT JOIN sessions s ON c.session_id = s.id
|
|
334
|
+
LEFT JOIN claim_files cf ON c.id = cf.claim_id
|
|
335
|
+
WHERE c.id = ?
|
|
336
|
+
GROUP BY c.id`
|
|
337
|
+
)
|
|
338
|
+
.bind(id)
|
|
339
|
+
.first<Claim & { session_name: string | null; file_paths: string | null }>();
|
|
329
340
|
|
|
330
|
-
|
|
341
|
+
if (!result) return null;
|
|
331
342
|
|
|
332
343
|
return {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
id: result.id,
|
|
345
|
+
session_id: result.session_id,
|
|
346
|
+
intent: result.intent,
|
|
347
|
+
scope: result.scope,
|
|
348
|
+
status: result.status,
|
|
349
|
+
created_at: result.created_at,
|
|
350
|
+
updated_at: result.updated_at,
|
|
351
|
+
completed_summary: result.completed_summary,
|
|
352
|
+
files: result.file_paths ? result.file_paths.split('|||') : [],
|
|
353
|
+
session_name: result.session_name,
|
|
336
354
|
};
|
|
337
355
|
}
|
|
338
356
|
|
package/src/db/sqlite-adapter.ts
CHANGED
|
@@ -39,8 +39,7 @@ class SqlitePreparedStatement implements PreparedStatement {
|
|
|
39
39
|
|
|
40
40
|
constructor(
|
|
41
41
|
private db: Database.Database,
|
|
42
|
-
private sql: string
|
|
43
|
-
private onWrite?: () => void
|
|
42
|
+
private sql: string
|
|
44
43
|
) {}
|
|
45
44
|
|
|
46
45
|
bind(...values: unknown[]): PreparedStatement {
|
|
@@ -66,8 +65,8 @@ class SqlitePreparedStatement implements PreparedStatement {
|
|
|
66
65
|
async run(): Promise<{ meta: { changes: number } }> {
|
|
67
66
|
const stmt = this.db.prepare(this.sql);
|
|
68
67
|
const result = stmt.run(...this.bindings);
|
|
69
|
-
//
|
|
70
|
-
|
|
68
|
+
// Note: wal_autocheckpoint handles periodic checkpoints automatically
|
|
69
|
+
// No need to checkpoint after every write - reduces I/O overhead
|
|
71
70
|
return {
|
|
72
71
|
meta: { changes: result.changes },
|
|
73
72
|
};
|
|
@@ -104,12 +103,13 @@ class SqliteDatabase implements DatabaseAdapter {
|
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
// Force checkpoint to make changes visible to other processes
|
|
106
|
+
// Only called after batch operations, not after individual writes
|
|
107
107
|
checkpoint(): void {
|
|
108
108
|
this.db.pragma('wal_checkpoint(PASSIVE)');
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
prepare(sql: string): PreparedStatement {
|
|
112
|
-
return new SqlitePreparedStatement(this.db, sql
|
|
112
|
+
return new SqlitePreparedStatement(this.db, sql);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
|
|
@@ -124,7 +124,7 @@ class SqliteDatabase implements DatabaseAdapter {
|
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
const results = transaction();
|
|
127
|
-
// Checkpoint after batch write
|
|
127
|
+
// Checkpoint after batch write to ensure visibility to other processes
|
|
128
128
|
this.checkpoint();
|
|
129
129
|
return results;
|
|
130
130
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Zod schemas for MCP tool input validation
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// Common schemas
|
|
5
|
+
export const sessionIdSchema = z.string().min(1, 'session_id is required');
|
|
6
|
+
export const claimIdSchema = z.string().min(1, 'claim_id is required');
|
|
7
|
+
export const filePathSchema = z.string().min(1);
|
|
8
|
+
export const filesArraySchema = z.array(filePathSchema).min(1, 'At least one file is required');
|
|
9
|
+
|
|
10
|
+
// Symbol claim schema
|
|
11
|
+
export const symbolClaimSchema = z.object({
|
|
12
|
+
file: z.string().min(1),
|
|
13
|
+
symbols: z.array(z.string().min(1)).min(1),
|
|
14
|
+
symbol_type: z.enum(['function', 'class', 'method', 'variable', 'block', 'other']).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const symbolClaimsArraySchema = z.array(symbolClaimSchema);
|
|
18
|
+
|
|
19
|
+
// Claim scope schema
|
|
20
|
+
export const claimScopeSchema = z.enum(['small', 'medium', 'large']).default('medium');
|
|
21
|
+
|
|
22
|
+
// Claim status schema
|
|
23
|
+
export const claimStatusSchema = z.enum(['completed', 'abandoned']);
|
|
24
|
+
|
|
25
|
+
// Session tools input schemas
|
|
26
|
+
export const sessionStartSchema = z.object({
|
|
27
|
+
project_root: z.string().min(1, 'project_root is required'),
|
|
28
|
+
name: z.string().optional(),
|
|
29
|
+
machine_id: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const sessionEndSchema = z.object({
|
|
33
|
+
session_id: sessionIdSchema,
|
|
34
|
+
release_claims: z.enum(['complete', 'abandon']).default('abandon'),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const sessionListSchema = z.object({
|
|
38
|
+
include_inactive: z.boolean().optional(),
|
|
39
|
+
project_root: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const sessionHeartbeatSchema = z.object({
|
|
43
|
+
session_id: sessionIdSchema,
|
|
44
|
+
current_task: z.string().optional(),
|
|
45
|
+
todos: z.array(z.object({
|
|
46
|
+
content: z.string(),
|
|
47
|
+
status: z.enum(['pending', 'in_progress', 'completed']),
|
|
48
|
+
})).optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const statusUpdateSchema = z.object({
|
|
52
|
+
session_id: sessionIdSchema,
|
|
53
|
+
current_task: z.string().optional(),
|
|
54
|
+
todos: z.array(z.object({
|
|
55
|
+
content: z.string(),
|
|
56
|
+
status: z.enum(['pending', 'in_progress', 'completed']),
|
|
57
|
+
})).optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const configSchema = z.object({
|
|
61
|
+
session_id: sessionIdSchema,
|
|
62
|
+
mode: z.enum(['strict', 'smart', 'bypass']).optional(),
|
|
63
|
+
allow_release_others: z.boolean().optional(),
|
|
64
|
+
auto_release_stale: z.boolean().optional(),
|
|
65
|
+
stale_threshold_hours: z.number().min(0).optional(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Claim tools input schemas
|
|
69
|
+
export const claimCreateSchema = z.object({
|
|
70
|
+
session_id: sessionIdSchema,
|
|
71
|
+
files: z.array(filePathSchema).optional(),
|
|
72
|
+
symbols: symbolClaimsArraySchema.optional(),
|
|
73
|
+
intent: z.string().min(1, 'intent is required'),
|
|
74
|
+
scope: claimScopeSchema.optional(),
|
|
75
|
+
}).refine(
|
|
76
|
+
(data) => (data.files && data.files.length > 0) || (data.symbols && data.symbols.length > 0),
|
|
77
|
+
{ message: 'Either files or symbols must be provided' }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export const claimCheckSchema = z.object({
|
|
81
|
+
files: filesArraySchema,
|
|
82
|
+
symbols: symbolClaimsArraySchema.optional(),
|
|
83
|
+
session_id: z.string().optional(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const claimReleaseSchema = z.object({
|
|
87
|
+
session_id: sessionIdSchema,
|
|
88
|
+
claim_id: claimIdSchema,
|
|
89
|
+
status: claimStatusSchema,
|
|
90
|
+
summary: z.string().optional(),
|
|
91
|
+
force: z.boolean().optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const claimListSchema = z.object({
|
|
95
|
+
session_id: z.string().optional(),
|
|
96
|
+
status: z.enum(['active', 'completed', 'abandoned', 'all']).optional(),
|
|
97
|
+
project_root: z.string().optional(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Message tools input schemas
|
|
101
|
+
export const messageSendSchema = z.object({
|
|
102
|
+
from_session_id: sessionIdSchema,
|
|
103
|
+
to_session_id: z.string().optional(),
|
|
104
|
+
content: z.string().min(1, 'content is required'),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const messageListSchema = z.object({
|
|
108
|
+
session_id: sessionIdSchema,
|
|
109
|
+
unread_only: z.boolean().optional(),
|
|
110
|
+
mark_as_read: z.boolean().optional(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Decision tools input schemas
|
|
114
|
+
export const decisionAddSchema = z.object({
|
|
115
|
+
session_id: sessionIdSchema,
|
|
116
|
+
category: z.enum(['architecture', 'naming', 'api', 'database', 'ui', 'other']).optional(),
|
|
117
|
+
title: z.string().min(1, 'title is required'),
|
|
118
|
+
description: z.string().min(1, 'description is required'),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const decisionListSchema = z.object({
|
|
122
|
+
category: z.enum(['architecture', 'naming', 'api', 'database', 'ui', 'other']).optional(),
|
|
123
|
+
limit: z.number().min(1).max(100).optional(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// LSP tools input schemas
|
|
127
|
+
// Using z.ZodType to properly type recursive schema
|
|
128
|
+
type LspSymbol = {
|
|
129
|
+
name: string;
|
|
130
|
+
kind: number;
|
|
131
|
+
range?: { start: { line: number; character: number }; end: { line: number; character: number } };
|
|
132
|
+
children?: LspSymbol[];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const lspSymbolSchema: z.ZodType<LspSymbol> = z.object({
|
|
136
|
+
name: z.string(),
|
|
137
|
+
kind: z.number(),
|
|
138
|
+
range: z.object({
|
|
139
|
+
start: z.object({ line: z.number(), character: z.number() }),
|
|
140
|
+
end: z.object({ line: z.number(), character: z.number() }),
|
|
141
|
+
}).optional(),
|
|
142
|
+
children: z.lazy(() => z.array(lspSymbolSchema)).optional(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const analyzeSymbolsSchema = z.object({
|
|
146
|
+
session_id: sessionIdSchema,
|
|
147
|
+
files: z.array(z.object({
|
|
148
|
+
file: z.string(),
|
|
149
|
+
symbols: z.array(lspSymbolSchema),
|
|
150
|
+
})),
|
|
151
|
+
check_symbols: z.array(z.string()).optional(),
|
|
152
|
+
references: z.array(z.object({
|
|
153
|
+
symbol: z.string(),
|
|
154
|
+
file: z.string(),
|
|
155
|
+
references: z.array(z.object({
|
|
156
|
+
file: z.string(),
|
|
157
|
+
line: z.number(),
|
|
158
|
+
context: z.string().optional(),
|
|
159
|
+
})),
|
|
160
|
+
})).optional(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const validateSymbolsSchema = z.object({
|
|
164
|
+
file: z.string().min(1),
|
|
165
|
+
symbols: z.array(z.string().min(1)),
|
|
166
|
+
lsp_symbols: z.array(lspSymbolSchema),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const storeReferencesSchema = z.object({
|
|
170
|
+
session_id: sessionIdSchema,
|
|
171
|
+
references: z.array(z.object({
|
|
172
|
+
source_file: z.string(),
|
|
173
|
+
source_symbol: z.string(),
|
|
174
|
+
references: z.array(z.object({
|
|
175
|
+
file: z.string(),
|
|
176
|
+
line: z.number(),
|
|
177
|
+
context: z.string().optional(),
|
|
178
|
+
})),
|
|
179
|
+
})),
|
|
180
|
+
clear_existing: z.boolean().optional(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const impactAnalysisSchema = z.object({
|
|
184
|
+
session_id: sessionIdSchema,
|
|
185
|
+
file: z.string().min(1),
|
|
186
|
+
symbol: z.string().min(1),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Helper function to validate and return parsed data or error result
|
|
190
|
+
export function validateInput<T>(
|
|
191
|
+
schema: z.ZodSchema<T>,
|
|
192
|
+
data: unknown
|
|
193
|
+
): { success: true; data: T } | { success: false; error: string } {
|
|
194
|
+
const result = schema.safeParse(data);
|
|
195
|
+
if (result.success) {
|
|
196
|
+
return { success: true, data: result.data };
|
|
197
|
+
}
|
|
198
|
+
const errors = result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
|
199
|
+
return { success: false, error: errors };
|
|
200
|
+
}
|
package/src/mcp/tools/claim.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
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 {
|
|
6
|
+
import type { SessionConfig } 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
|
+
import { claimCreateSchema, claimCheckSchema, claimReleaseSchema, claimListSchema, validateInput } from '../schemas.js';
|
|
9
10
|
|
|
10
11
|
export const claimTools: McpTool[] = [
|
|
11
12
|
{
|
|
@@ -156,37 +157,17 @@ export async function handleClaimTool(
|
|
|
156
157
|
): Promise<McpToolResult> {
|
|
157
158
|
switch (name) {
|
|
158
159
|
case 'collab_claim': {
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const intent = args.intent as string | undefined;
|
|
163
|
-
const scope = (args.scope as ClaimScope) ?? 'medium';
|
|
164
|
-
|
|
165
|
-
// Input validation
|
|
166
|
-
if (!sessionId || typeof sessionId !== 'string') {
|
|
160
|
+
// Validate input with Zod schema
|
|
161
|
+
const validation = validateInput(claimCreateSchema, args);
|
|
162
|
+
if (!validation.success) {
|
|
167
163
|
return createToolResult(
|
|
168
|
-
JSON.stringify({ error: 'INVALID_INPUT', message:
|
|
164
|
+
JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
|
|
169
165
|
true
|
|
170
166
|
);
|
|
171
167
|
}
|
|
172
168
|
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
|
|
176
|
-
|
|
177
|
-
if (!hasFiles && !hasSymbols) {
|
|
178
|
-
return createToolResult(
|
|
179
|
-
JSON.stringify({ error: 'INVALID_INPUT', message: 'Either files or symbols must be provided' }),
|
|
180
|
-
true
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!intent || typeof intent !== 'string' || intent.trim() === '') {
|
|
185
|
-
return createToolResult(
|
|
186
|
-
JSON.stringify({ error: 'INVALID_INPUT', message: 'intent is required' }),
|
|
187
|
-
true
|
|
188
|
-
);
|
|
189
|
-
}
|
|
169
|
+
const { session_id: sessionId, files, symbols, intent, scope = 'medium' } = validation.data;
|
|
170
|
+
const hasSymbols = symbols && symbols.length > 0;
|
|
190
171
|
|
|
191
172
|
// Verify session exists and is active
|
|
192
173
|
const session = await getSession(db, sessionId);
|
|
@@ -209,10 +190,8 @@ export async function handleClaimTool(
|
|
|
209
190
|
}
|
|
210
191
|
const fileList = Array.from(allFiles);
|
|
211
192
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// Create the claim
|
|
193
|
+
// Create the claim FIRST (atomic operation)
|
|
194
|
+
// This ensures our claim is registered before checking conflicts
|
|
216
195
|
const { claim } = await createClaim(db, {
|
|
217
196
|
session_id: sessionId,
|
|
218
197
|
files: fileList,
|
|
@@ -221,6 +200,11 @@ export async function handleClaimTool(
|
|
|
221
200
|
symbols: hasSymbols ? symbols : undefined,
|
|
222
201
|
});
|
|
223
202
|
|
|
203
|
+
// Check for conflicts AFTER creating claim
|
|
204
|
+
// This eliminates race condition: if two sessions claim simultaneously,
|
|
205
|
+
// both will see each other's claims and can coordinate
|
|
206
|
+
const conflicts = await checkConflicts(db, fileList, sessionId, symbols);
|
|
207
|
+
|
|
224
208
|
if (conflicts.length > 0) {
|
|
225
209
|
// Group conflicts by type (file vs symbol)
|
|
226
210
|
const fileConflicts = conflicts.filter((c) => c.conflict_level === 'file');
|
|
@@ -273,18 +257,17 @@ export async function handleClaimTool(
|
|
|
273
257
|
}
|
|
274
258
|
|
|
275
259
|
case 'collab_check': {
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// Input validation
|
|
281
|
-
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
260
|
+
// Validate input with Zod schema
|
|
261
|
+
const validation = validateInput(claimCheckSchema, args);
|
|
262
|
+
if (!validation.success) {
|
|
282
263
|
return createToolResult(
|
|
283
|
-
JSON.stringify({ error: 'INVALID_INPUT', message:
|
|
264
|
+
JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
|
|
284
265
|
true
|
|
285
266
|
);
|
|
286
267
|
}
|
|
287
268
|
|
|
269
|
+
const { files, symbols, session_id: sessionId } = validation.data;
|
|
270
|
+
|
|
288
271
|
// Check todos status if session_id provided
|
|
289
272
|
let hasInProgressTodo = false;
|
|
290
273
|
let todosStatus: { total: number; in_progress: number; completed: number; pending: number } | null = null;
|
|
@@ -312,10 +295,6 @@ export async function handleClaimTool(
|
|
|
312
295
|
const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
|
|
313
296
|
const conflicts = await checkConflicts(db, files, sessionId, hasSymbols ? symbols : undefined);
|
|
314
297
|
|
|
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
298
|
// Build per-file status (considering both file and symbol conflicts)
|
|
320
299
|
const blockedFiles = new Set<string>();
|
|
321
300
|
const blockedSymbols = new Map<string, Set<string>>(); // file -> symbols
|
|
@@ -464,23 +443,17 @@ export async function handleClaimTool(
|
|
|
464
443
|
}
|
|
465
444
|
|
|
466
445
|
case 'collab_release': {
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
const summary = args.summary as string | undefined;
|
|
471
|
-
const force = args.force as boolean | undefined;
|
|
472
|
-
|
|
473
|
-
// Validate session_id
|
|
474
|
-
if (!sessionId || typeof sessionId !== 'string') {
|
|
446
|
+
// Validate input with Zod schema
|
|
447
|
+
const validation = validateInput(claimReleaseSchema, args);
|
|
448
|
+
if (!validation.success) {
|
|
475
449
|
return createToolResult(
|
|
476
|
-
JSON.stringify({
|
|
477
|
-
error: 'INVALID_INPUT',
|
|
478
|
-
message: 'session_id is required to verify ownership',
|
|
479
|
-
}),
|
|
450
|
+
JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
|
|
480
451
|
true
|
|
481
452
|
);
|
|
482
453
|
}
|
|
483
454
|
|
|
455
|
+
const { session_id: sessionId, claim_id: claimId, status, summary, force } = validation.data;
|
|
456
|
+
|
|
484
457
|
const claim = await getClaim(db, claimId);
|
|
485
458
|
if (!claim) {
|
|
486
459
|
return createToolResult(
|
|
@@ -559,11 +532,17 @@ export async function handleClaimTool(
|
|
|
559
532
|
}
|
|
560
533
|
|
|
561
534
|
case 'collab_claims_list': {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
535
|
+
// Validate input with Zod schema (all fields optional)
|
|
536
|
+
const validation = validateInput(claimListSchema, args);
|
|
537
|
+
if (!validation.success) {
|
|
538
|
+
return createToolResult(
|
|
539
|
+
JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
|
|
540
|
+
true
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const { session_id, status = 'active', project_root } = validation.data;
|
|
545
|
+
const claims = await listClaims(db, { session_id, status, project_root });
|
|
567
546
|
|
|
568
547
|
return createToolResult(
|
|
569
548
|
JSON.stringify(
|