@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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// mcp-server/tools/exportToCloud.js
|
|
2
|
+
import { SqliteAdapter } from '../adapters/SqliteAdapter.js';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export async function exportToCloud({ mcp_token, api_url }) {
|
|
7
|
+
const resolvedApiUrl = api_url || process.env.PROPLAN_API_URL || 'https://project-planner-7zw4.onrender.com';
|
|
8
|
+
|
|
9
|
+
if (!mcp_token) {
|
|
10
|
+
throw new Error('mcp_token is required. Generate one at app.proplan.dev → Settings → Claude Code Integration.');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dbPath = join(process.cwd(), '.project-planner', 'db.sqlite');
|
|
14
|
+
if (!existsSync(dbPath)) {
|
|
15
|
+
return { inserted: 0, updated: 0, skipped: 0, message: 'No local database found. Nothing to sync.' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const local = new SqliteAdapter(dbPath);
|
|
19
|
+
const projects = local.getProjectsSyncStatus();
|
|
20
|
+
|
|
21
|
+
if (projects.length === 0) {
|
|
22
|
+
return { inserted: 0, updated: 0, skipped: 0, message: 'No local projects found. Create a project first with create_project or scan_repo.' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Determine which projects need syncing
|
|
26
|
+
const toSync = [];
|
|
27
|
+
const stats = { inserted: 0, updated: 0, skipped: 0 };
|
|
28
|
+
|
|
29
|
+
for (const project of projects) {
|
|
30
|
+
if (!project.last_synced_at) {
|
|
31
|
+
toSync.push({ ...project, _action: 'insert' });
|
|
32
|
+
} else if (new Date(project.updated_at) > new Date(project.last_synced_at)) {
|
|
33
|
+
toSync.push({ ...project, _action: 'update' });
|
|
34
|
+
} else {
|
|
35
|
+
stats.skipped++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (toSync.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
inserted: 0,
|
|
42
|
+
updated: 0,
|
|
43
|
+
skipped: stats.skipped,
|
|
44
|
+
dashboardUrl: 'https://project-planner-7zw4.onrender.com/dashboard',
|
|
45
|
+
message: 'All projects are already up to date.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Push to backend
|
|
50
|
+
const res = await fetch(`${resolvedApiUrl}/api/mcp/sync`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
Authorization: `Bearer ${mcp_token}`,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
projects: toSync.map(p => ({
|
|
58
|
+
id: p.id,
|
|
59
|
+
title: p.title,
|
|
60
|
+
content: p.content,
|
|
61
|
+
created_at: p.created_at || p.updated_at, // use actual created_at, fall back to updated_at if null
|
|
62
|
+
updated_at: p.updated_at,
|
|
63
|
+
})),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
69
|
+
throw new Error(err.error || `Sync failed: ${res.status}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
|
|
75
|
+
// Mark synced projects in local DB
|
|
76
|
+
for (const project of toSync) {
|
|
77
|
+
local.markSynced(project.id, now);
|
|
78
|
+
if (project._action === 'insert') stats.inserted++;
|
|
79
|
+
else stats.updated++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
inserted: stats.inserted,
|
|
84
|
+
updated: stats.updated,
|
|
85
|
+
skipped: stats.skipped,
|
|
86
|
+
dashboardUrl: data.dashboardUrl,
|
|
87
|
+
message: data.message,
|
|
88
|
+
nextStep: `Sign in at ${data.dashboardUrl} to view your projects.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// mcp-server/tools/getNextTasks.js
|
|
2
|
+
|
|
3
|
+
export async function getNextTasks(adapter, args) {
|
|
4
|
+
if (!args.project_id) throw new Error('project_id is required');
|
|
5
|
+
const limit = Math.min(args.limit ?? 5, 20);
|
|
6
|
+
|
|
7
|
+
const data = await adapter.getProject(args.project_id);
|
|
8
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
9
|
+
|
|
10
|
+
let roadmap;
|
|
11
|
+
try {
|
|
12
|
+
roadmap = JSON.parse(data.content);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
15
|
+
}
|
|
16
|
+
const results = [];
|
|
17
|
+
|
|
18
|
+
const phases = [...(roadmap.phases || [])].sort((a, b) => a.order - b.order);
|
|
19
|
+
for (const phase of phases) {
|
|
20
|
+
if (results.length >= limit) break;
|
|
21
|
+
const milestones = [...(phase.milestones || [])].sort((a, b) => a.order - b.order);
|
|
22
|
+
for (const milestone of milestones) {
|
|
23
|
+
if (results.length >= limit) break;
|
|
24
|
+
const tasks = [...(milestone.tasks || [])].sort((a, b) => a.order - b.order);
|
|
25
|
+
for (const task of tasks) {
|
|
26
|
+
if (results.length >= limit) break;
|
|
27
|
+
if (task.status === 'pending' || task.status === 'in_progress') {
|
|
28
|
+
results.push({
|
|
29
|
+
taskId: task.id,
|
|
30
|
+
title: task.title,
|
|
31
|
+
description: task.description ?? '',
|
|
32
|
+
status: task.status,
|
|
33
|
+
technology: task.technology ?? null,
|
|
34
|
+
phaseTitle: phase.title,
|
|
35
|
+
milestoneTitle: milestone.title,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// mcp-server/tools/getProjectRoadmap.js
|
|
2
|
+
|
|
3
|
+
export async function getProjectRoadmap(adapter, args) {
|
|
4
|
+
const data = await adapter.getProject(args.project_id);
|
|
5
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
6
|
+
|
|
7
|
+
let roadmap;
|
|
8
|
+
try {
|
|
9
|
+
roadmap = JSON.parse(data.content);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!args.summary_only) return roadmap;
|
|
15
|
+
|
|
16
|
+
// summary_only: strip descriptions and notes from every task
|
|
17
|
+
return {
|
|
18
|
+
...roadmap,
|
|
19
|
+
phases: (roadmap.phases || []).map(phase => ({
|
|
20
|
+
...phase,
|
|
21
|
+
milestones: (phase.milestones || []).map(milestone => ({
|
|
22
|
+
...milestone,
|
|
23
|
+
tasks: (milestone.tasks || []).map(({ description, notes, ...task }) => task),
|
|
24
|
+
})),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// mcp-server/tools/getProjectStatus.js
|
|
2
|
+
|
|
3
|
+
function countTasks(phases) {
|
|
4
|
+
let total = 0, completed = 0, inProgress = 0;
|
|
5
|
+
for (const phase of phases) {
|
|
6
|
+
for (const milestone of (phase.milestones || [])) {
|
|
7
|
+
for (const task of (milestone.tasks || [])) {
|
|
8
|
+
total++;
|
|
9
|
+
if (task.status === 'completed') completed++;
|
|
10
|
+
else if (task.status === 'in_progress') inProgress++;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return { total, completed, inProgress };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildHandoff(data, roadmap, limit = 5) {
|
|
18
|
+
const allTasks = [];
|
|
19
|
+
for (const phase of (roadmap.phases || [])) {
|
|
20
|
+
for (const milestone of (phase.milestones || [])) {
|
|
21
|
+
for (const task of (milestone.tasks || [])) {
|
|
22
|
+
const notes = Array.isArray(task.notes) ? task.notes : [];
|
|
23
|
+
const lastNote = notes.length > 0 ? notes[notes.length - 1] : null;
|
|
24
|
+
allTasks.push({
|
|
25
|
+
id: task.id,
|
|
26
|
+
title: task.title,
|
|
27
|
+
status: task.status || 'pending',
|
|
28
|
+
phase: phase.title,
|
|
29
|
+
milestone: milestone.title,
|
|
30
|
+
lastNote: lastNote ? { text: lastNote.text, createdAt: lastNote.createdAt } : null,
|
|
31
|
+
_lastNoteTime: lastNote ? new Date(lastNote.createdAt).getTime() : 0,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
allTasks.sort((a, b) => {
|
|
38
|
+
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
|
|
39
|
+
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
|
|
40
|
+
return b._lastNoteTime - a._lastNoteTime;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const sessions = Array.isArray(roadmap.sessions) ? roadmap.sessions : [];
|
|
44
|
+
return {
|
|
45
|
+
projectGoal: roadmap.projectGoal || null,
|
|
46
|
+
lastSession: sessions.length > 0 ? sessions[sessions.length - 1] : null,
|
|
47
|
+
recentTasks: allTasks.slice(0, limit).map(({ _lastNoteTime, ...t }) => t),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getProjectStatus(adapter, args) {
|
|
52
|
+
if (args.project_id) {
|
|
53
|
+
const data = await adapter.getProject(args.project_id);
|
|
54
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
55
|
+
|
|
56
|
+
let roadmap;
|
|
57
|
+
try { roadmap = JSON.parse(data.content); }
|
|
58
|
+
catch { throw new Error(`Project ${data.id} has corrupted roadmap data`); }
|
|
59
|
+
|
|
60
|
+
const { total, completed, inProgress } = countTasks(roadmap.phases || []);
|
|
61
|
+
const currentPhase = (roadmap.phases || []).find(p =>
|
|
62
|
+
(p.milestones || []).some(m => (m.tasks || []).some(t => t.status !== 'completed'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const status = {
|
|
66
|
+
id: data.id,
|
|
67
|
+
title: data.title,
|
|
68
|
+
totalPhases: (roadmap.phases || []).length,
|
|
69
|
+
totalTasks: total,
|
|
70
|
+
completedTasks: completed,
|
|
71
|
+
inProgressTasks: inProgress,
|
|
72
|
+
completionPercent: total === 0 ? 0 : Math.round((completed / total) * 100),
|
|
73
|
+
currentPhase: currentPhase?.title ?? null,
|
|
74
|
+
tech_metadata: roadmap.tech_metadata ?? null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (args.include_handoff) {
|
|
78
|
+
const handoff = buildHandoff(data, roadmap, args.last_n_tasks ?? 5);
|
|
79
|
+
status.projectGoal = handoff.projectGoal;
|
|
80
|
+
status.lastSession = handoff.lastSession;
|
|
81
|
+
status.recentTasks = handoff.recentTasks;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return status;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rows = await adapter.listProjects();
|
|
88
|
+
return rows.map(row => {
|
|
89
|
+
let roadmap;
|
|
90
|
+
try { roadmap = JSON.parse(row.content); }
|
|
91
|
+
catch { return { id: row.id, title: row.title, totalTasks: 0, completedTasks: 0, completionPercent: 0, error: 'corrupted roadmap data' }; }
|
|
92
|
+
const { total, completed } = countTasks(roadmap.phases || []);
|
|
93
|
+
return {
|
|
94
|
+
id: row.id,
|
|
95
|
+
title: row.title,
|
|
96
|
+
totalTasks: total,
|
|
97
|
+
completedTasks: completed,
|
|
98
|
+
completionPercent: total === 0 ? 0 : Math.round((completed / total) * 100),
|
|
99
|
+
tech_metadata: roadmap.tech_metadata ?? null,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// mcp-server/tools/getSessionHandoff.js
|
|
2
|
+
|
|
3
|
+
export async function getSessionHandoff(adapter, args) {
|
|
4
|
+
const data = await adapter.getProject(args.project_id);
|
|
5
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
6
|
+
|
|
7
|
+
const limit = args.last_n_tasks ?? 5;
|
|
8
|
+
|
|
9
|
+
let roadmap;
|
|
10
|
+
try {
|
|
11
|
+
roadmap = JSON.parse(data.content);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Flatten all tasks with their phase/milestone context
|
|
17
|
+
const allTasks = [];
|
|
18
|
+
for (const phase of (roadmap.phases || [])) {
|
|
19
|
+
for (const milestone of (phase.milestones || [])) {
|
|
20
|
+
for (const task of (milestone.tasks || [])) {
|
|
21
|
+
const notes = Array.isArray(task.notes) ? task.notes : [];
|
|
22
|
+
const lastNote = notes.length > 0 ? notes[notes.length - 1] : null;
|
|
23
|
+
allTasks.push({
|
|
24
|
+
id: task.id,
|
|
25
|
+
title: task.title,
|
|
26
|
+
status: task.status || 'pending',
|
|
27
|
+
phase: phase.title,
|
|
28
|
+
milestone: milestone.title,
|
|
29
|
+
lastNote: lastNote ? { text: lastNote.text, createdAt: lastNote.createdAt } : null,
|
|
30
|
+
_lastNoteTime: lastNote ? new Date(lastNote.createdAt).getTime() : 0,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Sort: in_progress first, then by most recent note descending
|
|
37
|
+
allTasks.sort((a, b) => {
|
|
38
|
+
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
|
|
39
|
+
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
|
|
40
|
+
return b._lastNoteTime - a._lastNoteTime;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const recentTasks = allTasks.slice(0, limit).map(({ _lastNoteTime, ...task }) => task);
|
|
44
|
+
|
|
45
|
+
// Summarise overall progress
|
|
46
|
+
const totalTasks = allTasks.length;
|
|
47
|
+
const completedTasks = allTasks.filter(t => t.status === 'completed').length;
|
|
48
|
+
const inProgressTasks = allTasks.filter(t => t.status === 'in_progress').length;
|
|
49
|
+
|
|
50
|
+
// Last session summary (most recent only)
|
|
51
|
+
const sessions = Array.isArray(roadmap.sessions) ? roadmap.sessions : [];
|
|
52
|
+
const lastSession = sessions.length > 0 ? sessions[sessions.length - 1] : null;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
projectGoal: roadmap.projectGoal || null,
|
|
56
|
+
project: {
|
|
57
|
+
id: data.id,
|
|
58
|
+
title: data.title,
|
|
59
|
+
totalTasks,
|
|
60
|
+
completedTasks,
|
|
61
|
+
inProgressTasks,
|
|
62
|
+
completionPercent: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
|
63
|
+
},
|
|
64
|
+
lastSession,
|
|
65
|
+
recentTasks,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// mcp-server/tools/getTasks.js
|
|
2
|
+
//
|
|
3
|
+
// Filter tasks by status, phase_id, and/or keyword.
|
|
4
|
+
// Returns a flat list of matching tasks with their phase/milestone context.
|
|
5
|
+
|
|
6
|
+
export async function getTasks(adapter, args) {
|
|
7
|
+
if (!args.project_id) throw new Error('project_id is required');
|
|
8
|
+
|
|
9
|
+
const data = await adapter.getProject(args.project_id);
|
|
10
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
11
|
+
|
|
12
|
+
let roadmap;
|
|
13
|
+
try {
|
|
14
|
+
roadmap = JSON.parse(data.content);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { status, phase_id, keyword } = args;
|
|
20
|
+
const limit = Math.min(args.limit ?? 100, 500);
|
|
21
|
+
const kwLower = keyword ? keyword.toLowerCase() : null;
|
|
22
|
+
|
|
23
|
+
const results = [];
|
|
24
|
+
const phases = [...(roadmap.phases || [])].sort((a, b) => a.order - b.order);
|
|
25
|
+
|
|
26
|
+
outer: for (const phase of phases) {
|
|
27
|
+
if (phase_id && phase.id !== phase_id) continue;
|
|
28
|
+
|
|
29
|
+
const milestones = [...(phase.milestones || [])].sort((a, b) => a.order - b.order);
|
|
30
|
+
for (const milestone of milestones) {
|
|
31
|
+
const tasks = [...(milestone.tasks || [])].sort((a, b) => a.order - b.order);
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
if (results.length >= limit) break outer;
|
|
34
|
+
if (status && task.status !== status) continue;
|
|
35
|
+
|
|
36
|
+
if (kwLower) {
|
|
37
|
+
const haystack = [task.title, task.description ?? '', task.technology ?? '']
|
|
38
|
+
.join(' ')
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
if (!haystack.includes(kwLower)) continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
results.push({
|
|
44
|
+
taskId: task.id,
|
|
45
|
+
title: task.title,
|
|
46
|
+
description: task.description ?? '',
|
|
47
|
+
status: task.status,
|
|
48
|
+
technology: task.technology ?? null,
|
|
49
|
+
phaseId: phase.id,
|
|
50
|
+
phaseTitle: phase.title,
|
|
51
|
+
milestoneId: milestone.id,
|
|
52
|
+
milestoneTitle: milestone.title,
|
|
53
|
+
notes: task.notes ?? [],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { total: results.length, tasks: results, limit };
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// mcp-server/tools/renameProject.js
|
|
2
|
+
|
|
3
|
+
export async function renameProject(adapter, args) {
|
|
4
|
+
if (!args.new_title || !args.new_title.trim()) {
|
|
5
|
+
throw new Error('new_title is required and cannot be empty');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const data = await adapter.getProject(args.project_id);
|
|
9
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
10
|
+
|
|
11
|
+
const oldTitle = data.title;
|
|
12
|
+
const newTitle = args.new_title.trim();
|
|
13
|
+
|
|
14
|
+
await adapter.renameProject(args.project_id, newTitle, new Date().toISOString());
|
|
15
|
+
|
|
16
|
+
return { old_title: oldTitle, new_title: newTitle };
|
|
17
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// mcp-server/tools/scanRepo.js
|
|
2
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative, extname, basename } from 'path';
|
|
4
|
+
import { canAnalyze, analyzeFile } from '../lib/fileAnalyzer.js';
|
|
5
|
+
|
|
6
|
+
// djb2 hash — fast, no dependencies
|
|
7
|
+
function djb2(str) {
|
|
8
|
+
let hash = 5381;
|
|
9
|
+
for (let i = 0; i < str.length; i++) {
|
|
10
|
+
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
|
11
|
+
hash = hash >>> 0; // keep unsigned 32-bit
|
|
12
|
+
}
|
|
13
|
+
return hash.toString(16);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EXCLUDED_DIRS = new Set([
|
|
17
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
18
|
+
'__pycache__', '.cache', '.turbo', 'out', '.vercel',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const KEY_FILE_NAMES = ['package.json', 'README.md', 'readme.md', 'README.txt'];
|
|
22
|
+
|
|
23
|
+
function buildTree(dir, rootDir, lines = [], depth = 0) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
33
|
+
if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
|
|
34
|
+
|
|
35
|
+
const indent = ' '.repeat(depth);
|
|
36
|
+
lines.push(`${indent}${entry.isDirectory() ? entry.name + '/' : entry.name}`);
|
|
37
|
+
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
buildTree(join(dir, entry.name), rootDir, lines, depth + 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findSourceFiles(dir, found = []) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
findSourceFiles(join(dir, entry.name), found);
|
|
57
|
+
} else if (canAnalyze(entry.name)) {
|
|
58
|
+
found.push(join(dir, entry.name));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findMarkdownFiles(dir, found = []) {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
findMarkdownFiles(join(dir, entry.name), found);
|
|
76
|
+
} else if (extname(entry.name).toLowerCase() === '.md') {
|
|
77
|
+
found.push(join(dir, entry.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readFileSafe(filePath) {
|
|
84
|
+
try {
|
|
85
|
+
return readFileSync(filePath, 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function scanRepo(args) {
|
|
92
|
+
const scanPath = args?.path || process.cwd();
|
|
93
|
+
|
|
94
|
+
// If a specific file path is provided (not a directory), read just that file
|
|
95
|
+
let isSingleFile = false;
|
|
96
|
+
try {
|
|
97
|
+
const stat = statSync(scanPath);
|
|
98
|
+
isSingleFile = stat.isFile();
|
|
99
|
+
} catch {
|
|
100
|
+
// path doesn't exist — fall through to treat as directory
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isSingleFile) {
|
|
104
|
+
const content = readFileSafe(scanPath);
|
|
105
|
+
return {
|
|
106
|
+
tree: basename(scanPath),
|
|
107
|
+
keyFiles: content ? [{ path: basename(scanPath), content }] : [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Full directory scan
|
|
112
|
+
const treeLines = buildTree(scanPath, scanPath);
|
|
113
|
+
const tree = treeLines.join('\n');
|
|
114
|
+
|
|
115
|
+
const keyFiles = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
|
|
118
|
+
// Always include root-level key files first
|
|
119
|
+
for (const name of KEY_FILE_NAMES) {
|
|
120
|
+
const filePath = join(scanPath, name);
|
|
121
|
+
if (existsSync(filePath)) {
|
|
122
|
+
const rel = relative(scanPath, filePath);
|
|
123
|
+
if (!seen.has(rel)) {
|
|
124
|
+
const content = readFileSafe(filePath);
|
|
125
|
+
if (content) { keyFiles.push({ path: rel, content }); seen.add(rel); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Markdown files — capped at 15 files, each truncated to 30KB
|
|
131
|
+
// Enough for large repos with extensive docs, without token blowout
|
|
132
|
+
const MD_FILE_LIMIT = 15;
|
|
133
|
+
const MD_FILE_MAX_BYTES = 30 * 1024;
|
|
134
|
+
const mdFiles = findMarkdownFiles(scanPath);
|
|
135
|
+
let mdCount = 0;
|
|
136
|
+
for (const filePath of mdFiles) {
|
|
137
|
+
if (mdCount >= MD_FILE_LIMIT) break;
|
|
138
|
+
const rel = relative(scanPath, filePath);
|
|
139
|
+
if (!seen.has(rel)) {
|
|
140
|
+
let content = readFileSafe(filePath);
|
|
141
|
+
if (content) {
|
|
142
|
+
// Truncate individual file if over 30KB
|
|
143
|
+
if (Buffer.byteLength(content, 'utf8') > MD_FILE_MAX_BYTES) {
|
|
144
|
+
content = content.slice(0, MD_FILE_MAX_BYTES) + '\n[truncated]';
|
|
145
|
+
}
|
|
146
|
+
keyFiles.push({ path: rel, content });
|
|
147
|
+
seen.add(rel);
|
|
148
|
+
mdCount++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Size guard: truncate file contents if total payload exceeds 200KB
|
|
154
|
+
const SIZE_LIMIT = 200 * 1024;
|
|
155
|
+
let totalSize = Buffer.byteLength(tree, 'utf8');
|
|
156
|
+
let truncated = false;
|
|
157
|
+
const guardedFiles = [];
|
|
158
|
+
|
|
159
|
+
for (const file of keyFiles) {
|
|
160
|
+
const fileSize = Buffer.byteLength(file.content, 'utf8');
|
|
161
|
+
if (totalSize + fileSize > SIZE_LIMIT) {
|
|
162
|
+
truncated = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
guardedFiles.push(file);
|
|
166
|
+
totalSize += fileSize;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const treeHash = djb2(tree);
|
|
170
|
+
const result = { tree, treeHash, keyFiles: guardedFiles };
|
|
171
|
+
if (truncated) result.truncated = true;
|
|
172
|
+
|
|
173
|
+
// Structural analysis for source files (JS/TS/Python/Go/Rust)
|
|
174
|
+
// Returns summaries (functions, classes, imports, exports) instead of raw content
|
|
175
|
+
const sourceFiles = findSourceFiles(scanPath);
|
|
176
|
+
const sourceAnalysis = [];
|
|
177
|
+
const SOURCE_LIMIT = 150 * 1024; // 150KB cap for structural analysis
|
|
178
|
+
let sourceSize = 0;
|
|
179
|
+
|
|
180
|
+
for (const filePath of sourceFiles) {
|
|
181
|
+
const content = readFileSafe(filePath);
|
|
182
|
+
if (!content) continue;
|
|
183
|
+
const fileSize = Buffer.byteLength(content, 'utf8');
|
|
184
|
+
if (sourceSize + fileSize > SOURCE_LIMIT) break;
|
|
185
|
+
const analysis = analyzeFile(relative(scanPath, filePath), content);
|
|
186
|
+
if (analysis) {
|
|
187
|
+
sourceAnalysis.push(analysis);
|
|
188
|
+
sourceSize += fileSize;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (sourceAnalysis.length > 0) {
|
|
193
|
+
result.sourceAnalysis = sourceAnalysis;
|
|
194
|
+
|
|
195
|
+
// fileMap — hierarchical tree of source files with their structural summaries
|
|
196
|
+
// Stored in tech_metadata for the dashboard to render as a code map
|
|
197
|
+
const fileMap = {};
|
|
198
|
+
for (const entry of sourceAnalysis) {
|
|
199
|
+
const parts = entry.path.split('/');
|
|
200
|
+
const fileName = parts.pop();
|
|
201
|
+
const dir = parts.join('/') || '.';
|
|
202
|
+
if (!fileMap[dir]) fileMap[dir] = [];
|
|
203
|
+
fileMap[dir].push({
|
|
204
|
+
file: fileName,
|
|
205
|
+
language: entry.language,
|
|
206
|
+
functions: entry.functions,
|
|
207
|
+
classes: entry.classes,
|
|
208
|
+
exports: entry.exports,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
result.fileMap = fileMap;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// mcp-server/tools/setProjectGoal.js
|
|
2
|
+
|
|
3
|
+
export async function setProjectGoal(adapter, args) {
|
|
4
|
+
if (!args.goal || !args.goal.trim()) {
|
|
5
|
+
throw new Error('goal is required and cannot be empty');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const data = await adapter.getProject(args.project_id);
|
|
9
|
+
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
10
|
+
|
|
11
|
+
let roadmap;
|
|
12
|
+
try {
|
|
13
|
+
roadmap = JSON.parse(data.content);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const oldGoal = roadmap.projectGoal || null;
|
|
19
|
+
roadmap.projectGoal = args.goal.trim();
|
|
20
|
+
|
|
21
|
+
await adapter.saveProject(args.project_id, data.title, JSON.stringify(roadmap), new Date().toISOString());
|
|
22
|
+
|
|
23
|
+
return { old_goal: oldGoal, new_goal: roadmap.projectGoal };
|
|
24
|
+
}
|