@proplandev/mcp 1.0.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.
- package/adapters/BackendApiAdapter.js +75 -0
- package/adapters/SqliteAdapter.js +82 -0
- package/adapters/SupabaseAdapter.js +76 -0
- package/auth.js +22 -0
- package/bin/init.js +165 -0
- package/index.js +437 -0
- package/lib/fileAnalyzer.js +220 -0
- package/package.json +59 -0
- package/supabase.js +4 -0
- package/tools/addMilestone.js +38 -0
- package/tools/addNoteToTask.js +35 -0
- package/tools/addPhase.js +34 -0
- package/tools/addSessionSummary.js +39 -0
- package/tools/addTask.js +52 -0
- package/tools/createProject.js +80 -0
- package/tools/deleteMilestone.js +39 -0
- package/tools/deletePhase.js +35 -0
- package/tools/deleteProject.js +35 -0
- package/tools/deleteTask.js +40 -0
- package/tools/editMilestone.js +39 -0
- package/tools/editPhase.js +34 -0
- package/tools/editTask.js +51 -0
- package/tools/exportToCloud.js +90 -0
- package/tools/getNextTasks.js +43 -0
- package/tools/getProjectRoadmap.js +27 -0
- package/tools/getProjectStatus.js +102 -0
- package/tools/getSessionHandoff.js +67 -0
- package/tools/getTasks.js +60 -0
- package/tools/renameProject.js +17 -0
- package/tools/scanRepo.js +215 -0
- package/tools/setProjectGoal.js +24 -0
- package/tools/syncProjects.js +99 -0
- package/tools/updateTaskStatus.js +47 -0
package/index.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-server/index.js
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
import { BackendApiAdapter } from './adapters/BackendApiAdapter.js';
|
|
9
|
+
import { SqliteAdapter } from './adapters/SqliteAdapter.js';
|
|
10
|
+
|
|
11
|
+
import { getProjectStatus } from './tools/getProjectStatus.js';
|
|
12
|
+
import { getNextTasks } from './tools/getNextTasks.js';
|
|
13
|
+
import { updateTaskStatus } from './tools/updateTaskStatus.js';
|
|
14
|
+
import { addNoteToTask } from './tools/addNoteToTask.js';
|
|
15
|
+
import { getProjectRoadmap } from './tools/getProjectRoadmap.js';
|
|
16
|
+
import { addTask } from './tools/addTask.js';
|
|
17
|
+
import { addMilestone } from './tools/addMilestone.js';
|
|
18
|
+
import { addPhase } from './tools/addPhase.js';
|
|
19
|
+
import { editTask } from './tools/editTask.js';
|
|
20
|
+
import { editMilestone } from './tools/editMilestone.js';
|
|
21
|
+
import { editPhase } from './tools/editPhase.js';
|
|
22
|
+
import { deleteTask } from './tools/deleteTask.js';
|
|
23
|
+
import { deleteMilestone } from './tools/deleteMilestone.js';
|
|
24
|
+
import { deletePhase } from './tools/deletePhase.js';
|
|
25
|
+
import { createProject } from './tools/createProject.js';
|
|
26
|
+
import { scanRepo } from './tools/scanRepo.js';
|
|
27
|
+
import { exportToCloud } from './tools/exportToCloud.js';
|
|
28
|
+
import { deleteProject } from './tools/deleteProject.js';
|
|
29
|
+
import { renameProject } from './tools/renameProject.js';
|
|
30
|
+
import { setProjectGoal } from './tools/setProjectGoal.js';
|
|
31
|
+
import { addSessionSummary } from './tools/addSessionSummary.js';
|
|
32
|
+
import { getTasks } from './tools/getTasks.js';
|
|
33
|
+
|
|
34
|
+
// ─── Mode detection ───────────────────────────────────────────────────────────
|
|
35
|
+
const { MCP_TOKEN } = process.env;
|
|
36
|
+
const PROPLAN_API_URL = process.env.PROPLAN_API_URL || 'https://project-planner-7zw4.onrender.com';
|
|
37
|
+
const isCloudMode = !!MCP_TOKEN;
|
|
38
|
+
|
|
39
|
+
let adapter;
|
|
40
|
+
|
|
41
|
+
if (isCloudMode) {
|
|
42
|
+
adapter = new BackendApiAdapter(MCP_TOKEN, PROPLAN_API_URL);
|
|
43
|
+
} else {
|
|
44
|
+
const dbPath = join(process.cwd(), '.project-planner', 'db.sqlite');
|
|
45
|
+
adapter = new SqliteAdapter(dbPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.error(`[ProPlan MCP] Starting in ${isCloudMode ? 'CLOUD' : 'LOCAL'} mode`);
|
|
49
|
+
|
|
50
|
+
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
51
|
+
const server = new McpServer({
|
|
52
|
+
name: 'project-planner',
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
server.tool(
|
|
57
|
+
'get_project_status',
|
|
58
|
+
'Get completion status for one or all projects. Pass include_handoff: true to get session resume context (goal, last session, recent tasks) in the same call.',
|
|
59
|
+
{
|
|
60
|
+
project_id: z.string().optional().describe('UUID of the project. Omit to get all projects.'),
|
|
61
|
+
include_handoff: z.boolean().optional().describe('true = include session resume context (projectGoal, lastSession, recentTasks). Requires project_id.'),
|
|
62
|
+
last_n_tasks: z.number().int().min(1).max(20).optional().describe('How many recent tasks to include in handoff (default 5). Only used when include_handoff: true.'),
|
|
63
|
+
},
|
|
64
|
+
async ({ project_id, include_handoff, last_n_tasks }) => {
|
|
65
|
+
const result = await getProjectStatus(adapter, { project_id, include_handoff, last_n_tasks });
|
|
66
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
server.tool(
|
|
71
|
+
'get_next_tasks',
|
|
72
|
+
'Get the next pending or in-progress tasks for a project, ordered by phase and milestone.',
|
|
73
|
+
{
|
|
74
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
75
|
+
limit: z.number().int().min(1).max(20).optional().describe('How many tasks to return (default 5, max 20).'),
|
|
76
|
+
},
|
|
77
|
+
async ({ project_id, limit }) => {
|
|
78
|
+
const result = await getNextTasks(adapter, { project_id, limit });
|
|
79
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
server.tool(
|
|
84
|
+
'update_task_status',
|
|
85
|
+
'Update the status of a task. Optionally attach a note in the same operation (max 150 chars).',
|
|
86
|
+
{
|
|
87
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
88
|
+
task_id: z.string().describe('ID of the task (e.g. "task-1").'),
|
|
89
|
+
status: z.enum(['pending', 'in_progress', 'completed']).describe('New status.'),
|
|
90
|
+
note: z.string().max(150).optional().describe('Intent note (in_progress) or outcome note (completed). Max 150 chars.'),
|
|
91
|
+
},
|
|
92
|
+
async ({ project_id, task_id, status, note }) => {
|
|
93
|
+
const result = await updateTaskStatus(adapter, { project_id, task_id, status, note });
|
|
94
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
server.tool(
|
|
99
|
+
'add_note_to_task',
|
|
100
|
+
'Attach a progress note to a task. Notes are appended and never overwrite existing ones.',
|
|
101
|
+
{
|
|
102
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
103
|
+
task_id: z.string().describe('ID of the task.'),
|
|
104
|
+
note: z.string().min(1).describe('The note text to attach.'),
|
|
105
|
+
},
|
|
106
|
+
async ({ project_id, task_id, note }) => {
|
|
107
|
+
const result = await addNoteToTask(adapter, { project_id, task_id, note });
|
|
108
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.tool(
|
|
113
|
+
'get_project_roadmap',
|
|
114
|
+
'Get the roadmap for a project. Use summary_only: true for a lightweight view (titles only, no descriptions or notes).',
|
|
115
|
+
{
|
|
116
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
117
|
+
summary_only: z.boolean().optional().describe('true = titles only, omit descriptions and notes.'),
|
|
118
|
+
},
|
|
119
|
+
async ({ project_id, summary_only }) => {
|
|
120
|
+
const result = await getProjectRoadmap(adapter, { project_id, summary_only });
|
|
121
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.tool(
|
|
126
|
+
'add_task',
|
|
127
|
+
'Add a new task to a milestone. Use dry_run: true first to preview, then dry_run: false to apply.',
|
|
128
|
+
{
|
|
129
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
130
|
+
phase_id: z.string().describe('ID of the target phase (e.g. "phase-1").'),
|
|
131
|
+
milestone_id: z.string().describe('ID of the target milestone (e.g. "milestone-1").'),
|
|
132
|
+
title: z.string().min(1).describe('Task title.'),
|
|
133
|
+
description: z.string().optional().describe('Optional task description.'),
|
|
134
|
+
technology: z.string().optional().describe('Optional technology tag.'),
|
|
135
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
136
|
+
},
|
|
137
|
+
async (args) => {
|
|
138
|
+
const result = await addTask(adapter, args);
|
|
139
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.tool(
|
|
144
|
+
'add_milestone',
|
|
145
|
+
'Add a new milestone to a phase. Use dry_run: true first to preview, then dry_run: false to apply.',
|
|
146
|
+
{
|
|
147
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
148
|
+
phase_id: z.string().describe('ID of the target phase.'),
|
|
149
|
+
title: z.string().min(1).describe('Milestone title.'),
|
|
150
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
151
|
+
},
|
|
152
|
+
async (args) => {
|
|
153
|
+
const result = await addMilestone(adapter, args);
|
|
154
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
server.tool(
|
|
159
|
+
'add_phase',
|
|
160
|
+
'Add a new phase to a project. Use dry_run: true first to preview, then dry_run: false to apply.',
|
|
161
|
+
{
|
|
162
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
163
|
+
title: z.string().min(1).describe('Phase title.'),
|
|
164
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
165
|
+
},
|
|
166
|
+
async (args) => {
|
|
167
|
+
const result = await addPhase(adapter, args);
|
|
168
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
'edit_task',
|
|
174
|
+
'Edit a task title, description, or technology. Use dry_run: true first to preview. At least one of title/description/technology required.',
|
|
175
|
+
{
|
|
176
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
177
|
+
task_id: z.string().describe('ID of the task (e.g. "task-1").'),
|
|
178
|
+
title: z.string().optional().describe('New task title.'),
|
|
179
|
+
description: z.string().optional().describe('New description.'),
|
|
180
|
+
technology: z.string().optional().describe('New technology tag.'),
|
|
181
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
182
|
+
},
|
|
183
|
+
async (args) => {
|
|
184
|
+
const result = await editTask(adapter, args);
|
|
185
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
server.tool(
|
|
190
|
+
'edit_milestone',
|
|
191
|
+
'Rename a milestone. Use dry_run: true first to preview, then dry_run: false to apply.',
|
|
192
|
+
{
|
|
193
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
194
|
+
milestone_id: z.string().describe('ID of the milestone (e.g. "milestone-1").'),
|
|
195
|
+
title: z.string().min(1).describe('New milestone title.'),
|
|
196
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
197
|
+
},
|
|
198
|
+
async (args) => {
|
|
199
|
+
const result = await editMilestone(adapter, args);
|
|
200
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.tool(
|
|
205
|
+
'edit_phase',
|
|
206
|
+
'Rename a phase. Use dry_run: true first to preview, then dry_run: false to apply.',
|
|
207
|
+
{
|
|
208
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
209
|
+
phase_id: z.string().describe('ID of the phase (e.g. "phase-1").'),
|
|
210
|
+
title: z.string().min(1).describe('New phase title.'),
|
|
211
|
+
dry_run: z.boolean().describe('true = preview only, false = apply the change.'),
|
|
212
|
+
},
|
|
213
|
+
async (args) => {
|
|
214
|
+
const result = await editPhase(adapter, args);
|
|
215
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
server.tool(
|
|
220
|
+
'delete_task',
|
|
221
|
+
'Permanently delete a task. Use dry_run: true first to see what will be deleted, then dry_run: false to apply. This is irreversible.',
|
|
222
|
+
{
|
|
223
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
224
|
+
task_id: z.string().describe('ID of the task (e.g. "task-1").'),
|
|
225
|
+
dry_run: z.boolean().describe('true = preview only, false = permanently delete.'),
|
|
226
|
+
},
|
|
227
|
+
async (args) => {
|
|
228
|
+
const result = await deleteTask(adapter, args);
|
|
229
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
server.tool(
|
|
234
|
+
'delete_milestone',
|
|
235
|
+
'Permanently delete a milestone and all its tasks. Use dry_run: true first to see what will be deleted. This is irreversible.',
|
|
236
|
+
{
|
|
237
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
238
|
+
milestone_id: z.string().describe('ID of the milestone (e.g. "milestone-1").'),
|
|
239
|
+
dry_run: z.boolean().describe('true = preview only, false = permanently delete.'),
|
|
240
|
+
},
|
|
241
|
+
async (args) => {
|
|
242
|
+
const result = await deleteMilestone(adapter, args);
|
|
243
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.tool(
|
|
248
|
+
'delete_phase',
|
|
249
|
+
'Permanently delete a phase and all its milestones and tasks. Use dry_run: true first to see what will be deleted. This is irreversible.',
|
|
250
|
+
{
|
|
251
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
252
|
+
phase_id: z.string().describe('ID of the phase (e.g. "phase-1").'),
|
|
253
|
+
dry_run: z.boolean().describe('true = preview only, false = permanently delete.'),
|
|
254
|
+
},
|
|
255
|
+
async (args) => {
|
|
256
|
+
const result = await deletePhase(adapter, args);
|
|
257
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
server.tool(
|
|
262
|
+
'create_project',
|
|
263
|
+
'Create a new project with a full phase/milestone/task structure.',
|
|
264
|
+
{
|
|
265
|
+
title: z.string().min(1).describe('Project title.'),
|
|
266
|
+
description: z.string().optional().describe('Short project description.'),
|
|
267
|
+
timeline: z.string().optional().describe('Estimated timeline (e.g. "3 months"). For cloud/PM use.'),
|
|
268
|
+
experienceLevel: z.string().optional().describe('Developer experience level. For cloud/PM use.'),
|
|
269
|
+
technologies: z.array(z.string()).optional().describe('Technology stack array.'),
|
|
270
|
+
phases: z.array(z.object({
|
|
271
|
+
title: z.string(),
|
|
272
|
+
milestones: z.array(z.object({
|
|
273
|
+
title: z.string(),
|
|
274
|
+
tasks: z.array(z.object({
|
|
275
|
+
title: z.string(),
|
|
276
|
+
description: z.string().optional(),
|
|
277
|
+
technology: z.string().optional(),
|
|
278
|
+
})),
|
|
279
|
+
})),
|
|
280
|
+
})).describe('Full project structure — all phases, milestones, and tasks.'),
|
|
281
|
+
},
|
|
282
|
+
async (args) => {
|
|
283
|
+
const result = await createProject(adapter, args);
|
|
284
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
server.tool(
|
|
289
|
+
'scan_repo',
|
|
290
|
+
'Scan a repository directory tree and key files. Pass project_id to persist tech stack metadata and enable hash-based cache.',
|
|
291
|
+
{
|
|
292
|
+
path: z.string().optional().describe('Path to scan. Defaults to cwd. Pass a file path to read just that file.'),
|
|
293
|
+
project_id: z.string().optional().describe('UUID of the project to persist tech_metadata into. If hash unchanged since last scan, returns cached metadata instead of re-scanning.'),
|
|
294
|
+
},
|
|
295
|
+
async ({ path, project_id }) => {
|
|
296
|
+
// If project_id given, check for a cached hash first
|
|
297
|
+
if (project_id) {
|
|
298
|
+
const projectData = await adapter.getProject(project_id);
|
|
299
|
+
if (projectData) {
|
|
300
|
+
const roadmap = JSON.parse(projectData.content);
|
|
301
|
+
const cached = roadmap.tech_metadata;
|
|
302
|
+
|
|
303
|
+
const scanResult = scanRepo({ path });
|
|
304
|
+
|
|
305
|
+
// Cache hit — tree unchanged, return cached metadata
|
|
306
|
+
if (cached && cached.treeHash === scanResult.treeHash) {
|
|
307
|
+
return {
|
|
308
|
+
content: [{
|
|
309
|
+
type: 'text',
|
|
310
|
+
text: JSON.stringify({ ...scanResult, tech_metadata: cached, cached: true }, null, 2),
|
|
311
|
+
}],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Cache miss — extract and persist new tech_metadata
|
|
316
|
+
const sourceAnalysis = scanResult.sourceAnalysis ?? [];
|
|
317
|
+
const languages = [...new Set(sourceAnalysis.map(f => f.language))];
|
|
318
|
+
const importCounts = {};
|
|
319
|
+
for (const f of sourceAnalysis) {
|
|
320
|
+
for (const imp of f.imports) {
|
|
321
|
+
importCounts[imp] = (importCounts[imp] || 0) + 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const topImports = Object.entries(importCounts)
|
|
325
|
+
.sort((a, b) => b[1] - a[1])
|
|
326
|
+
.slice(0, 20)
|
|
327
|
+
.map(([name]) => name);
|
|
328
|
+
|
|
329
|
+
const tech_metadata = {
|
|
330
|
+
languages,
|
|
331
|
+
topImports,
|
|
332
|
+
fileCount: sourceAnalysis.length,
|
|
333
|
+
treeHash: scanResult.treeHash,
|
|
334
|
+
fileMap: scanResult.fileMap ?? {},
|
|
335
|
+
scannedAt: new Date().toISOString(),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
roadmap.tech_metadata = tech_metadata;
|
|
339
|
+
adapter.saveProject(project_id, projectData.title, JSON.stringify(roadmap), new Date().toISOString());
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
content: [{
|
|
343
|
+
type: 'text',
|
|
344
|
+
text: JSON.stringify({ ...scanResult, tech_metadata, cached: false }, null, 2),
|
|
345
|
+
}],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const result = scanRepo({ path });
|
|
351
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
server.tool(
|
|
356
|
+
'export_to_cloud',
|
|
357
|
+
'Sync local projects to the ProPlan dashboard. Only works in local mode. New projects are inserted, changed projects updated, unchanged ones skipped.',
|
|
358
|
+
{
|
|
359
|
+
mcp_token: z.string().describe('Your MCP token from app.proplan.dev → Settings → Claude Code Integration.'),
|
|
360
|
+
api_url: z.string().optional().describe('Override the API base URL. Defaults to the production ProPlan backend.'),
|
|
361
|
+
},
|
|
362
|
+
async ({ mcp_token, api_url }) => {
|
|
363
|
+
const result = await exportToCloud({ mcp_token, api_url });
|
|
364
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
server.tool(
|
|
369
|
+
'delete_project',
|
|
370
|
+
'Permanently delete a project and all its phases, milestones, and tasks. Use dry_run: true first to see what will be deleted, then dry_run: false to apply. This is irreversible.',
|
|
371
|
+
{
|
|
372
|
+
project_id: z.string().describe('UUID of the project to delete.'),
|
|
373
|
+
dry_run: z.boolean().describe('true = preview only, false = permanently delete.'),
|
|
374
|
+
},
|
|
375
|
+
async (args) => {
|
|
376
|
+
const result = await deleteProject(adapter, args);
|
|
377
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
server.tool(
|
|
382
|
+
'rename_project',
|
|
383
|
+
'Rename a project. Updates the title only — all phases, milestones, and tasks are preserved.',
|
|
384
|
+
{
|
|
385
|
+
project_id: z.string().describe('UUID of the project to rename.'),
|
|
386
|
+
new_title: z.string().min(1).describe('New project title.'),
|
|
387
|
+
},
|
|
388
|
+
async (args) => {
|
|
389
|
+
const result = await renameProject(adapter, args);
|
|
390
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
server.tool(
|
|
395
|
+
'set_project_goal',
|
|
396
|
+
'Set or update the permanent project goal. Returned in every session handoff as the north-star anchor.',
|
|
397
|
+
{
|
|
398
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
399
|
+
goal: z.string().min(1).describe('1-3 sentence project goal. What is being built and what does success look like?'),
|
|
400
|
+
},
|
|
401
|
+
async (args) => {
|
|
402
|
+
const result = await setProjectGoal(adapter, args);
|
|
403
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
server.tool(
|
|
408
|
+
'add_session_summary',
|
|
409
|
+
'Save a session summary (what was done, decisions made, what is next). Capped at 10 entries.',
|
|
410
|
+
{
|
|
411
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
412
|
+
summary: z.string().min(1).describe('3-5 sentence session summary: what was done, decisions made, what is next.'),
|
|
413
|
+
},
|
|
414
|
+
async (args) => {
|
|
415
|
+
const result = await addSessionSummary(adapter, args);
|
|
416
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
server.tool(
|
|
421
|
+
'get_tasks',
|
|
422
|
+
'Filter tasks by status, phase, and/or keyword. Returns a flat list with phase/milestone context.',
|
|
423
|
+
{
|
|
424
|
+
project_id: z.string().describe('UUID of the project.'),
|
|
425
|
+
status: z.enum(['pending', 'in_progress', 'completed']).optional().describe('Filter by task status.'),
|
|
426
|
+
phase_id: z.string().optional().describe('Filter to a specific phase (e.g. "phase-1").'),
|
|
427
|
+
keyword: z.string().optional().describe('Case-insensitive keyword search across title, description, and technology.'),
|
|
428
|
+
limit: z.number().int().min(1).max(500).optional().describe('Max tasks to return (default 100, max 500).'),
|
|
429
|
+
},
|
|
430
|
+
async (args) => {
|
|
431
|
+
const result = await getTasks(adapter, args);
|
|
432
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const transport = new StdioServerTransport();
|
|
437
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// mcp-server/lib/fileAnalyzer.js
|
|
2
|
+
//
|
|
3
|
+
// Structural file analyzer — extracts function names, class names, and imports
|
|
4
|
+
// from source files instead of returning raw content.
|
|
5
|
+
//
|
|
6
|
+
// Inspired by the AST-based approach in tirth8205/code-review-graph (MIT License).
|
|
7
|
+
// Copyright (c) 2026 Tirth Kanani — https://github.com/tirth8205/code-review-graph
|
|
8
|
+
//
|
|
9
|
+
// This implementation uses regex-based extraction for zero additional dependencies.
|
|
10
|
+
|
|
11
|
+
const ANALYZERS = {
|
|
12
|
+
// JavaScript / TypeScript
|
|
13
|
+
js: analyzeJS,
|
|
14
|
+
jsx: analyzeJS,
|
|
15
|
+
ts: analyzeJS,
|
|
16
|
+
tsx: analyzeJS,
|
|
17
|
+
mjs: analyzeJS,
|
|
18
|
+
cjs: analyzeJS,
|
|
19
|
+
|
|
20
|
+
// Python
|
|
21
|
+
py: analyzePython,
|
|
22
|
+
|
|
23
|
+
// Go
|
|
24
|
+
go: analyzeGo,
|
|
25
|
+
|
|
26
|
+
// Rust
|
|
27
|
+
rs: analyzeRust,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function getExtension(filePath) {
|
|
31
|
+
const parts = filePath.split('.');
|
|
32
|
+
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function canAnalyze(filePath) {
|
|
36
|
+
return !!ANALYZERS[getExtension(filePath)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Analyze a source file and return its structural summary.
|
|
41
|
+
* Returns null if the file type is not supported.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} filePath - relative path (used for display)
|
|
44
|
+
* @param {string} content - file content
|
|
45
|
+
* @returns {{ path, language, functions, classes, imports, exports } | null}
|
|
46
|
+
*/
|
|
47
|
+
export function analyzeFile(filePath, content) {
|
|
48
|
+
const ext = getExtension(filePath);
|
|
49
|
+
const analyzer = ANALYZERS[ext];
|
|
50
|
+
if (!analyzer) return null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = analyzer(content);
|
|
54
|
+
return { path: filePath, ...result };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── JavaScript / TypeScript ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function analyzeJS(content) {
|
|
63
|
+
const functions = new Set();
|
|
64
|
+
const classes = new Set();
|
|
65
|
+
const imports = new Set();
|
|
66
|
+
const exports = new Set();
|
|
67
|
+
|
|
68
|
+
const lines = content.split('\n');
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
|
|
73
|
+
// imports: import X from 'y' / import { X } from 'y' / import 'y'
|
|
74
|
+
const imp = trimmed.match(/^import\s+(?:.+?\s+from\s+)?['"]([^'"]+)['"]/);
|
|
75
|
+
if (imp) { imports.add(imp[1]); continue; }
|
|
76
|
+
|
|
77
|
+
// require: const x = require('y')
|
|
78
|
+
const req = trimmed.match(/require\(['"]([^'"]+)['"]\)/);
|
|
79
|
+
if (req) { imports.add(req[1]); }
|
|
80
|
+
|
|
81
|
+
// export default function name / export function name / export async function name
|
|
82
|
+
const expFn = trimmed.match(/^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)/);
|
|
83
|
+
if (expFn) { functions.add(expFn[1]); exports.add(expFn[1]); continue; }
|
|
84
|
+
|
|
85
|
+
// export default class / export class
|
|
86
|
+
const expClass = trimmed.match(/^export\s+(?:default\s+)?class\s+(\w+)/);
|
|
87
|
+
if (expClass) { classes.add(expClass[1]); exports.add(expClass[1]); continue; }
|
|
88
|
+
|
|
89
|
+
// export const/let/var name = ...
|
|
90
|
+
const expConst = trimmed.match(/^export\s+(?:const|let|var)\s+(\w+)/);
|
|
91
|
+
if (expConst) { exports.add(expConst[1]); }
|
|
92
|
+
|
|
93
|
+
// export { name, name2 }
|
|
94
|
+
const expNamed = trimmed.match(/^export\s+\{([^}]+)\}/);
|
|
95
|
+
if (expNamed) {
|
|
96
|
+
expNamed[1].split(',').forEach(e => exports.add(e.trim().split(/\s+as\s+/)[0].trim()));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// function declaration (not export)
|
|
100
|
+
const fn = trimmed.match(/^(?:async\s+)?function\s+(\w+)/);
|
|
101
|
+
if (fn) { functions.add(fn[1]); continue; }
|
|
102
|
+
|
|
103
|
+
// arrow function assigned to const/let/var
|
|
104
|
+
const arrow = trimmed.match(/^(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(/);
|
|
105
|
+
if (arrow) { functions.add(arrow[1]); continue; }
|
|
106
|
+
|
|
107
|
+
// class declaration
|
|
108
|
+
const cls = trimmed.match(/^class\s+(\w+)/);
|
|
109
|
+
if (cls) { classes.add(cls[1]); continue; }
|
|
110
|
+
|
|
111
|
+
// class method shorthand: methodName(...) { or async methodName(...) {
|
|
112
|
+
const method = trimmed.match(/^(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
|
|
113
|
+
if (method && !['if', 'for', 'while', 'switch', 'catch'].includes(method[1])) {
|
|
114
|
+
functions.add(method[1]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
language: 'javascript',
|
|
120
|
+
functions: [...functions],
|
|
121
|
+
classes: [...classes],
|
|
122
|
+
imports: [...imports],
|
|
123
|
+
exports: [...exports],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Python ───────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function analyzePython(content) {
|
|
130
|
+
const functions = new Set();
|
|
131
|
+
const classes = new Set();
|
|
132
|
+
const imports = new Set();
|
|
133
|
+
|
|
134
|
+
for (const line of content.split('\n')) {
|
|
135
|
+
const trimmed = line.trim();
|
|
136
|
+
|
|
137
|
+
// import x / import x as y
|
|
138
|
+
const imp = trimmed.match(/^import\s+(\S+)/);
|
|
139
|
+
if (imp) { imports.add(imp[1].split('.')[0]); continue; }
|
|
140
|
+
|
|
141
|
+
// from x import y
|
|
142
|
+
const frm = trimmed.match(/^from\s+(\S+)\s+import/);
|
|
143
|
+
if (frm) { imports.add(frm[1]); continue; }
|
|
144
|
+
|
|
145
|
+
// def name(
|
|
146
|
+
const fn = trimmed.match(/^(?:async\s+)?def\s+(\w+)\s*\(/);
|
|
147
|
+
if (fn) { functions.add(fn[1]); continue; }
|
|
148
|
+
|
|
149
|
+
// class name
|
|
150
|
+
const cls = trimmed.match(/^class\s+(\w+)/);
|
|
151
|
+
if (cls) { classes.add(cls[1]); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
language: 'python',
|
|
156
|
+
functions: [...functions],
|
|
157
|
+
classes: [...classes],
|
|
158
|
+
imports: [...imports],
|
|
159
|
+
exports: [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Go ───────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function analyzeGo(content) {
|
|
166
|
+
const functions = new Set();
|
|
167
|
+
const imports = new Set();
|
|
168
|
+
|
|
169
|
+
for (const line of content.split('\n')) {
|
|
170
|
+
const trimmed = line.trim();
|
|
171
|
+
|
|
172
|
+
// import "pkg" or "pkg/sub"
|
|
173
|
+
const imp = trimmed.match(/^"([^"]+)"/);
|
|
174
|
+
if (imp) { imports.add(imp[1].split('/').pop()); continue; }
|
|
175
|
+
|
|
176
|
+
// func Name( or func (receiver) Name(
|
|
177
|
+
const fn = trimmed.match(/^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/);
|
|
178
|
+
if (fn && fn[1] !== 'init') { functions.add(fn[1]); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
language: 'go',
|
|
183
|
+
functions: [...functions],
|
|
184
|
+
classes: [],
|
|
185
|
+
imports: [...imports],
|
|
186
|
+
exports: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Rust ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function analyzeRust(content) {
|
|
193
|
+
const functions = new Set();
|
|
194
|
+
const imports = new Set();
|
|
195
|
+
const structs = new Set();
|
|
196
|
+
|
|
197
|
+
for (const line of content.split('\n')) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
|
|
200
|
+
// use crate::module or use std::x
|
|
201
|
+
const use_ = trimmed.match(/^use\s+([\w:]+)/);
|
|
202
|
+
if (use_) { imports.add(use_[1].split('::')[0]); continue; }
|
|
203
|
+
|
|
204
|
+
// pub fn name( / fn name(
|
|
205
|
+
const fn_ = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[<(]/);
|
|
206
|
+
if (fn_) { functions.add(fn_[1]); continue; }
|
|
207
|
+
|
|
208
|
+
// pub struct Name / struct Name
|
|
209
|
+
const struct_ = trimmed.match(/^(?:pub\s+)?struct\s+(\w+)/);
|
|
210
|
+
if (struct_) { structs.add(struct_[1]); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
language: 'rust',
|
|
215
|
+
functions: [...functions],
|
|
216
|
+
classes: [...structs],
|
|
217
|
+
imports: [...imports],
|
|
218
|
+
exports: [],
|
|
219
|
+
};
|
|
220
|
+
}
|