session-collab-mcp 0.3.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.
@@ -0,0 +1,358 @@
1
+ // Claim management tools (WIP declarations)
2
+
3
+ import type { D1Database } from '@cloudflare/workers-types';
4
+ import type { McpTool, McpToolResult } from '../protocol';
5
+ import { createToolResult } from '../protocol';
6
+ import type { ClaimScope } from '../../db/types';
7
+ import { createClaim, getClaim, listClaims, checkConflicts, releaseClaim, getSession } from '../../db/queries';
8
+
9
+ export const claimTools: McpTool[] = [
10
+ {
11
+ name: 'collab_claim',
12
+ description:
13
+ 'Declare files you are about to modify. Other sessions will see a warning before modifying the same files.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ session_id: {
18
+ type: 'string',
19
+ description: 'Your session ID',
20
+ },
21
+ files: {
22
+ type: 'array',
23
+ items: { type: 'string' },
24
+ description: "File paths to claim. Supports glob patterns like 'src/api/*'",
25
+ },
26
+ intent: {
27
+ type: 'string',
28
+ description: 'What you plan to do with these files',
29
+ },
30
+ scope: {
31
+ type: 'string',
32
+ enum: ['small', 'medium', 'large'],
33
+ description: 'Estimated scope: small(<30min), medium(30min-2hr), large(>2hr)',
34
+ },
35
+ },
36
+ required: ['session_id', 'files', 'intent'],
37
+ },
38
+ },
39
+ {
40
+ name: 'collab_check',
41
+ description:
42
+ 'Check if files are being worked on by other sessions. ALWAYS call this before deleting or significantly modifying files.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ files: {
47
+ type: 'array',
48
+ items: { type: 'string' },
49
+ description: 'File paths to check',
50
+ },
51
+ session_id: {
52
+ type: 'string',
53
+ description: 'Your session ID (to exclude your own claims from results)',
54
+ },
55
+ },
56
+ required: ['files'],
57
+ },
58
+ },
59
+ {
60
+ name: 'collab_release',
61
+ description: 'Release a claim when done or abandoning work.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ claim_id: {
66
+ type: 'string',
67
+ description: 'Claim ID to release',
68
+ },
69
+ status: {
70
+ type: 'string',
71
+ enum: ['completed', 'abandoned'],
72
+ description: 'Whether work was completed or abandoned',
73
+ },
74
+ summary: {
75
+ type: 'string',
76
+ description: 'Optional summary of what was done (for completed claims)',
77
+ },
78
+ },
79
+ required: ['claim_id', 'status'],
80
+ },
81
+ },
82
+ {
83
+ name: 'collab_claims_list',
84
+ description: 'List all WIP claims. Use to see what files are being worked on.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ session_id: {
89
+ type: 'string',
90
+ description: 'Filter by session ID',
91
+ },
92
+ status: {
93
+ type: 'string',
94
+ enum: ['active', 'completed', 'abandoned', 'all'],
95
+ description: 'Filter by claim status',
96
+ },
97
+ project_root: {
98
+ type: 'string',
99
+ description: 'Filter by project root',
100
+ },
101
+ },
102
+ },
103
+ },
104
+ ];
105
+
106
+ export async function handleClaimTool(
107
+ db: D1Database,
108
+ name: string,
109
+ args: Record<string, unknown>
110
+ ): Promise<McpToolResult> {
111
+ switch (name) {
112
+ case 'collab_claim': {
113
+ const sessionId = args.session_id as string | undefined;
114
+ const files = args.files as string[] | undefined;
115
+ const intent = args.intent as string | undefined;
116
+ const scope = (args.scope as ClaimScope) ?? 'medium';
117
+
118
+ // Input validation
119
+ if (!sessionId || typeof sessionId !== 'string') {
120
+ return createToolResult(
121
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'session_id is required' }),
122
+ true
123
+ );
124
+ }
125
+ if (!files || !Array.isArray(files) || files.length === 0) {
126
+ return createToolResult(
127
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'files array cannot be empty' }),
128
+ true
129
+ );
130
+ }
131
+ if (!intent || typeof intent !== 'string' || intent.trim() === '') {
132
+ return createToolResult(
133
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'intent is required' }),
134
+ true
135
+ );
136
+ }
137
+
138
+ // Verify session exists and is active
139
+ const session = await getSession(db, sessionId);
140
+ if (!session || session.status !== 'active') {
141
+ return createToolResult(
142
+ JSON.stringify({
143
+ error: 'SESSION_INVALID',
144
+ message: 'Session not found or inactive. Please start a new session.',
145
+ }),
146
+ true
147
+ );
148
+ }
149
+
150
+ // Check for conflicts before creating claim
151
+ const conflicts = await checkConflicts(db, files, sessionId);
152
+
153
+ // Create the claim
154
+ const { claim } = await createClaim(db, {
155
+ session_id: sessionId,
156
+ files,
157
+ intent,
158
+ scope,
159
+ });
160
+
161
+ if (conflicts.length > 0) {
162
+ // Group conflicts by claim
163
+ const conflictsByClaim = new Map<string, typeof conflicts>();
164
+ for (const c of conflicts) {
165
+ const existing = conflictsByClaim.get(c.claim_id) ?? [];
166
+ existing.push(c);
167
+ conflictsByClaim.set(c.claim_id, existing);
168
+ }
169
+
170
+ const conflictDetails = Array.from(conflictsByClaim.entries()).map(([claimId, items]) => ({
171
+ claim_id: claimId,
172
+ session_name: items[0].session_name,
173
+ intent: items[0].intent,
174
+ scope: items[0].scope,
175
+ overlapping_files: items.map((i) => i.file_path),
176
+ }));
177
+
178
+ return createToolResult(
179
+ JSON.stringify(
180
+ {
181
+ claim_id: claim.id,
182
+ status: 'created_with_conflicts',
183
+ conflicts: conflictDetails,
184
+ warning: `⚠️ ${conflicts.length} file(s) overlap with other sessions. Please coordinate before proceeding.`,
185
+ },
186
+ null,
187
+ 2
188
+ )
189
+ );
190
+ }
191
+
192
+ return createToolResult(
193
+ JSON.stringify({
194
+ claim_id: claim.id,
195
+ status: 'created',
196
+ files,
197
+ intent,
198
+ scope,
199
+ message: 'Claim created successfully. Other sessions will be warned about these files.',
200
+ })
201
+ );
202
+ }
203
+
204
+ case 'collab_check': {
205
+ const files = args.files as string[] | undefined;
206
+ const sessionId = args.session_id as string | undefined;
207
+
208
+ // Input validation
209
+ if (!files || !Array.isArray(files) || files.length === 0) {
210
+ return createToolResult(
211
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'files array cannot be empty' }),
212
+ true
213
+ );
214
+ }
215
+
216
+ // Check todos status if session_id provided
217
+ let hasInProgressTodo = false;
218
+ let todosStatus: { total: number; in_progress: number; completed: number; pending: number } | null = null;
219
+ if (sessionId) {
220
+ const session = await getSession(db, sessionId);
221
+ if (session?.todos) {
222
+ try {
223
+ const todos = JSON.parse(session.todos) as Array<{ status: string }>;
224
+ const inProgress = todos.filter((t) => t.status === 'in_progress').length;
225
+ const completed = todos.filter((t) => t.status === 'completed').length;
226
+ const pending = todos.filter((t) => t.status === 'pending').length;
227
+ hasInProgressTodo = inProgress > 0;
228
+ todosStatus = {
229
+ total: todos.length,
230
+ in_progress: inProgress,
231
+ completed,
232
+ pending,
233
+ };
234
+ } catch {
235
+ // Ignore parse errors
236
+ }
237
+ }
238
+ }
239
+
240
+ const conflicts = await checkConflicts(db, files, sessionId);
241
+
242
+ if (conflicts.length === 0) {
243
+ return createToolResult(
244
+ JSON.stringify({
245
+ has_conflicts: false,
246
+ safe: true,
247
+ message: 'These files are not being worked on by other sessions.',
248
+ has_in_progress_todo: hasInProgressTodo,
249
+ todos_status: todosStatus,
250
+ })
251
+ );
252
+ }
253
+
254
+ // Group by session for clearer output
255
+ const bySession = new Map<string, typeof conflicts>();
256
+ for (const c of conflicts) {
257
+ const key = c.session_id;
258
+ const existing = bySession.get(key) ?? [];
259
+ existing.push(c);
260
+ bySession.set(key, existing);
261
+ }
262
+
263
+ const conflictDetails = Array.from(bySession.entries()).map(([sessId, items]) => ({
264
+ session_id: sessId,
265
+ session_name: items[0].session_name,
266
+ intent: items[0].intent,
267
+ scope: items[0].scope,
268
+ files: items.map((i) => i.file_path),
269
+ started_at: items[0].created_at,
270
+ }));
271
+
272
+ return createToolResult(
273
+ JSON.stringify(
274
+ {
275
+ has_conflicts: true,
276
+ safe: false,
277
+ conflicts: conflictDetails,
278
+ warning: `⚠️ ${conflicts.length} file(s) are being worked on by ${bySession.size} other session(s). Coordinate before modifying.`,
279
+ has_in_progress_todo: hasInProgressTodo,
280
+ todos_status: todosStatus,
281
+ },
282
+ null,
283
+ 2
284
+ )
285
+ );
286
+ }
287
+
288
+ case 'collab_release': {
289
+ const claimId = args.claim_id as string;
290
+ const status = args.status as 'completed' | 'abandoned';
291
+ const summary = args.summary as string | undefined;
292
+
293
+ const claim = await getClaim(db, claimId);
294
+ if (!claim) {
295
+ return createToolResult(
296
+ JSON.stringify({
297
+ error: 'CLAIM_NOT_FOUND',
298
+ message: 'Claim not found. It may have already been released.',
299
+ }),
300
+ true
301
+ );
302
+ }
303
+
304
+ if (claim.status !== 'active') {
305
+ return createToolResult(
306
+ JSON.stringify({
307
+ error: 'CLAIM_ALREADY_RELEASED',
308
+ message: `Claim was already ${claim.status}.`,
309
+ }),
310
+ true
311
+ );
312
+ }
313
+
314
+ await releaseClaim(db, claimId, { status, summary });
315
+
316
+ return createToolResult(
317
+ JSON.stringify({
318
+ success: true,
319
+ message: `Claim ${status}. Files are now available for other sessions.`,
320
+ files: claim.files,
321
+ summary: summary ?? null,
322
+ })
323
+ );
324
+ }
325
+
326
+ case 'collab_claims_list': {
327
+ const claims = await listClaims(db, {
328
+ session_id: args.session_id as string | undefined,
329
+ status: (args.status as 'active' | 'completed' | 'abandoned' | 'all') ?? 'active',
330
+ project_root: args.project_root as string | undefined,
331
+ });
332
+
333
+ return createToolResult(
334
+ JSON.stringify(
335
+ {
336
+ claims: claims.map((c) => ({
337
+ id: c.id,
338
+ session_id: c.session_id,
339
+ session_name: c.session_name,
340
+ files: c.files,
341
+ intent: c.intent,
342
+ scope: c.scope,
343
+ status: c.status,
344
+ created_at: c.created_at,
345
+ completed_summary: c.completed_summary,
346
+ })),
347
+ total: claims.length,
348
+ },
349
+ null,
350
+ 2
351
+ )
352
+ );
353
+ }
354
+
355
+ default:
356
+ return createToolResult(`Unknown claim tool: ${name}`, true);
357
+ }
358
+ }
@@ -0,0 +1,124 @@
1
+ // Decision recording tools
2
+
3
+ import type { D1Database } from '@cloudflare/workers-types';
4
+ import type { McpTool, McpToolResult } from '../protocol';
5
+ import { createToolResult } from '../protocol';
6
+ import type { DecisionCategory } from '../../db/types';
7
+ import { addDecision, listDecisions, getSession } from '../../db/queries';
8
+
9
+ export const decisionTools: McpTool[] = [
10
+ {
11
+ name: 'collab_decision_add',
12
+ description: 'Record an architectural or design decision for team reference.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ session_id: {
17
+ type: 'string',
18
+ description: 'Your session ID',
19
+ },
20
+ category: {
21
+ type: 'string',
22
+ enum: ['architecture', 'naming', 'api', 'database', 'ui', 'other'],
23
+ description: 'Decision category',
24
+ },
25
+ title: {
26
+ type: 'string',
27
+ description: 'Brief title of the decision',
28
+ },
29
+ description: {
30
+ type: 'string',
31
+ description: 'Detailed description of the decision and rationale',
32
+ },
33
+ },
34
+ required: ['session_id', 'title', 'description'],
35
+ },
36
+ },
37
+ {
38
+ name: 'collab_decision_list',
39
+ description: 'List recorded decisions. Use to understand past architectural choices.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ category: {
44
+ type: 'string',
45
+ enum: ['architecture', 'naming', 'api', 'database', 'ui', 'other'],
46
+ description: 'Filter by category',
47
+ },
48
+ limit: {
49
+ type: 'number',
50
+ description: 'Maximum number of decisions to return',
51
+ },
52
+ },
53
+ },
54
+ },
55
+ ];
56
+
57
+ export async function handleDecisionTool(
58
+ db: D1Database,
59
+ name: string,
60
+ args: Record<string, unknown>
61
+ ): Promise<McpToolResult> {
62
+ switch (name) {
63
+ case 'collab_decision_add': {
64
+ const sessionId = args.session_id as string;
65
+ const category = args.category as DecisionCategory | undefined;
66
+ const title = args.title as string;
67
+ const description = args.description as string;
68
+
69
+ // Verify session
70
+ const session = await getSession(db, sessionId);
71
+ if (!session || session.status !== 'active') {
72
+ return createToolResult(
73
+ JSON.stringify({
74
+ error: 'SESSION_INVALID',
75
+ message: 'Session not found or inactive.',
76
+ }),
77
+ true
78
+ );
79
+ }
80
+
81
+ const decision = await addDecision(db, {
82
+ session_id: sessionId,
83
+ category,
84
+ title,
85
+ description,
86
+ });
87
+
88
+ return createToolResult(
89
+ JSON.stringify({
90
+ success: true,
91
+ decision_id: decision.id,
92
+ message: 'Decision recorded successfully.',
93
+ })
94
+ );
95
+ }
96
+
97
+ case 'collab_decision_list': {
98
+ const category = args.category as DecisionCategory | undefined;
99
+ const limit = (args.limit as number) ?? 20;
100
+
101
+ const decisions = await listDecisions(db, { category, limit });
102
+
103
+ return createToolResult(
104
+ JSON.stringify(
105
+ {
106
+ decisions: decisions.map((d) => ({
107
+ id: d.id,
108
+ category: d.category,
109
+ title: d.title,
110
+ description: d.description,
111
+ created_at: d.created_at,
112
+ })),
113
+ total: decisions.length,
114
+ },
115
+ null,
116
+ 2
117
+ )
118
+ );
119
+ }
120
+
121
+ default:
122
+ return createToolResult(`Unknown decision tool: ${name}`, true);
123
+ }
124
+ }
@@ -0,0 +1,150 @@
1
+ // Inter-session messaging tools
2
+
3
+ import type { D1Database } from '@cloudflare/workers-types';
4
+ import type { McpTool, McpToolResult } from '../protocol';
5
+ import { createToolResult } from '../protocol';
6
+ import { sendMessage, listMessages, getSession } from '../../db/queries';
7
+
8
+ export const messageTools: McpTool[] = [
9
+ {
10
+ name: 'collab_message_send',
11
+ description: 'Send a message to another session or broadcast to all sessions.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ properties: {
15
+ from_session_id: {
16
+ type: 'string',
17
+ description: 'Your session ID',
18
+ },
19
+ to_session_id: {
20
+ type: 'string',
21
+ description: 'Target session ID. Leave empty to broadcast to all.',
22
+ },
23
+ content: {
24
+ type: 'string',
25
+ description: 'Message content',
26
+ },
27
+ },
28
+ required: ['from_session_id', 'content'],
29
+ },
30
+ },
31
+ {
32
+ name: 'collab_message_list',
33
+ description: 'Read messages sent to your session.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ session_id: {
38
+ type: 'string',
39
+ description: 'Your session ID',
40
+ },
41
+ unread_only: {
42
+ type: 'boolean',
43
+ description: 'Only show unread messages',
44
+ },
45
+ mark_as_read: {
46
+ type: 'boolean',
47
+ description: 'Mark retrieved messages as read',
48
+ },
49
+ },
50
+ required: ['session_id'],
51
+ },
52
+ },
53
+ ];
54
+
55
+ export async function handleMessageTool(
56
+ db: D1Database,
57
+ name: string,
58
+ args: Record<string, unknown>
59
+ ): Promise<McpToolResult> {
60
+ switch (name) {
61
+ case 'collab_message_send': {
62
+ const fromSessionId = args.from_session_id as string;
63
+ const toSessionId = args.to_session_id as string | undefined;
64
+ const content = args.content as string;
65
+
66
+ // Verify sender session
67
+ const fromSession = await getSession(db, fromSessionId);
68
+ if (!fromSession || fromSession.status !== 'active') {
69
+ return createToolResult(
70
+ JSON.stringify({
71
+ error: 'SESSION_INVALID',
72
+ message: 'Your session is not active.',
73
+ }),
74
+ true
75
+ );
76
+ }
77
+
78
+ // Verify target session if specified
79
+ if (toSessionId) {
80
+ const toSession = await getSession(db, toSessionId);
81
+ if (!toSession || toSession.status !== 'active') {
82
+ return createToolResult(
83
+ JSON.stringify({
84
+ error: 'TARGET_SESSION_INVALID',
85
+ message: 'Target session not found or inactive.',
86
+ }),
87
+ true
88
+ );
89
+ }
90
+ }
91
+
92
+ const message = await sendMessage(db, {
93
+ from_session_id: fromSessionId,
94
+ to_session_id: toSessionId,
95
+ content,
96
+ });
97
+
98
+ return createToolResult(
99
+ JSON.stringify({
100
+ success: true,
101
+ message_id: message.id,
102
+ sent_to: toSessionId ?? 'all sessions (broadcast)',
103
+ message: 'Message sent successfully.',
104
+ })
105
+ );
106
+ }
107
+
108
+ case 'collab_message_list': {
109
+ const sessionId = args.session_id as string;
110
+ const unreadOnly = (args.unread_only as boolean) ?? true;
111
+ const markAsRead = (args.mark_as_read as boolean) ?? true;
112
+
113
+ const messages = await listMessages(db, {
114
+ session_id: sessionId,
115
+ unread_only: unreadOnly,
116
+ mark_as_read: markAsRead,
117
+ });
118
+
119
+ if (messages.length === 0) {
120
+ return createToolResult(
121
+ JSON.stringify({
122
+ messages: [],
123
+ message: unreadOnly ? 'No unread messages.' : 'No messages.',
124
+ })
125
+ );
126
+ }
127
+
128
+ return createToolResult(
129
+ JSON.stringify(
130
+ {
131
+ messages: messages.map((m) => ({
132
+ id: m.id,
133
+ from_session_id: m.from_session_id,
134
+ content: m.content,
135
+ created_at: m.created_at,
136
+ is_broadcast: m.to_session_id === null,
137
+ })),
138
+ total: messages.length,
139
+ marked_as_read: markAsRead,
140
+ },
141
+ null,
142
+ 2
143
+ )
144
+ );
145
+ }
146
+
147
+ default:
148
+ return createToolResult(`Unknown message tool: ${name}`, true);
149
+ }
150
+ }