@dalmasonto/taskflow-mcp 1.0.10 → 1.0.14
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/dist/agent-registry.d.ts +5 -1
- package/dist/agent-registry.js +29 -25
- package/dist/config.d.ts +19 -0
- package/dist/config.js +108 -0
- package/dist/db.js +23 -2
- package/dist/index.js +70 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +76 -0
- package/dist/sse.js +113 -18
- package/dist/tmux-bridge.js +14 -5
- package/dist/tools/activity.d.ts +10 -0
- package/dist/tools/activity.js +95 -3
- package/dist/tools/agent-inbox.js +8 -8
- package/dist/tools/agent.js +37 -3
- package/dist/tools/analytics.js +86 -3
- package/dist/tools/checkpoint.d.ts +27 -0
- package/dist/tools/checkpoint.js +105 -0
- package/dist/tools/notifications.js +4 -4
- package/dist/tools/projects.js +6 -6
- package/dist/tools/settings.js +24 -3
- package/dist/tools/tasks.d.ts +2 -0
- package/dist/tools/tasks.js +14 -8
- package/dist/tools/terminal.d.ts +2 -0
- package/dist/tools/terminal.js +60 -0
- package/dist/tools/timer.js +4 -4
- package/dist/types.d.ts +5 -1
- package/dist/types.js +2 -0
- package/package.json +1 -1
package/dist/tmux-bridge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { getDb } from './db.js';
|
|
3
3
|
import { getActivePort } from './sse.js';
|
|
4
4
|
import http from 'http';
|
|
@@ -9,11 +9,11 @@ function startSSEListener(options) {
|
|
|
9
9
|
function connect() {
|
|
10
10
|
const req = http.get(`http://localhost:${port}/events`, (res) => {
|
|
11
11
|
let buffer = '';
|
|
12
|
+
let eventType = ''; // persists across chunks — SSE fields may split across TCP segments
|
|
12
13
|
res.on('data', (chunk) => {
|
|
13
14
|
buffer += chunk.toString();
|
|
14
15
|
const lines = buffer.split('\n');
|
|
15
16
|
buffer = lines.pop() || '';
|
|
16
|
-
let eventType = '';
|
|
17
17
|
for (const line of lines) {
|
|
18
18
|
if (line.startsWith('event: ')) {
|
|
19
19
|
eventType = line.slice(7).trim();
|
|
@@ -26,6 +26,10 @@ function startSSEListener(options) {
|
|
|
26
26
|
catch { /* malformed JSON */ }
|
|
27
27
|
eventType = '';
|
|
28
28
|
}
|
|
29
|
+
else if (line === '') {
|
|
30
|
+
// SSE event delimiter — reset state for next event
|
|
31
|
+
eventType = '';
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
});
|
|
31
35
|
res.on('end', () => {
|
|
@@ -88,9 +92,14 @@ function injectAndMarkDelivered(id, text, tmuxPane) {
|
|
|
88
92
|
const db = getDb();
|
|
89
93
|
db.prepare('UPDATE agent_messages SET delivered = 1 WHERE id = ?').run(id);
|
|
90
94
|
try {
|
|
91
|
-
|
|
92
|
-
// Long text triggers tmux bracketed paste — delay then
|
|
93
|
-
|
|
95
|
+
execFileSync('tmux', ['send-keys', '-t', tmuxPane, text, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
96
|
+
// Long text triggers tmux bracketed paste — async delay then extra Enter (non-blocking)
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
try {
|
|
99
|
+
execFileSync('tmux', ['send-keys', '-t', tmuxPane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
100
|
+
}
|
|
101
|
+
catch { /* pane may have closed */ }
|
|
102
|
+
}, 1000);
|
|
94
103
|
console.error(`[bridge] delivered message ${id} to tmux pane ${tmuxPane}`);
|
|
95
104
|
}
|
|
96
105
|
catch (err) {
|
package/dist/tools/activity.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export declare function clearActivityLog(): Promise<{
|
|
|
15
15
|
text: string;
|
|
16
16
|
}[];
|
|
17
17
|
}>;
|
|
18
|
+
export declare function compactActivityLog(params: {
|
|
19
|
+
preserve_recent?: number;
|
|
20
|
+
max_entries?: number;
|
|
21
|
+
dry_run?: boolean;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
content: {
|
|
24
|
+
type: "text";
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
27
|
+
}>;
|
|
18
28
|
export declare function logDebug(params: {
|
|
19
29
|
message: string;
|
|
20
30
|
task_id?: number;
|
package/dist/tools/activity.js
CHANGED
|
@@ -31,6 +31,93 @@ export async function clearActivityLog() {
|
|
|
31
31
|
broadcastChange('activity', 'activity_cleared', {});
|
|
32
32
|
return successResponse({ deleted: countRow.count, message: 'Activity log cleared' });
|
|
33
33
|
}
|
|
34
|
+
// ─── compaction ──────────────────────────────────────────────────────
|
|
35
|
+
const DEFAULT_PRESERVE_RECENT = 50;
|
|
36
|
+
const DEFAULT_MAX_LOG_ENTRIES = 500;
|
|
37
|
+
/** Actions that are always preserved in full (never compacted) */
|
|
38
|
+
const MILESTONE_ACTIONS = new Set([
|
|
39
|
+
'task_created', 'task_completed', 'task_partial_done',
|
|
40
|
+
'project_created', 'project_deleted',
|
|
41
|
+
'agent_connected', 'agent_disconnected',
|
|
42
|
+
]);
|
|
43
|
+
export async function compactActivityLog(params) {
|
|
44
|
+
const db = getDb();
|
|
45
|
+
const preserveRecent = params.preserve_recent ?? DEFAULT_PRESERVE_RECENT;
|
|
46
|
+
const maxEntries = params.max_entries ?? DEFAULT_MAX_LOG_ENTRIES;
|
|
47
|
+
const totalCount = db.prepare('SELECT COUNT(*) AS count FROM activity_logs').get().count;
|
|
48
|
+
if (totalCount <= maxEntries) {
|
|
49
|
+
return successResponse({
|
|
50
|
+
compacted: false,
|
|
51
|
+
total_entries: totalCount,
|
|
52
|
+
message: `Log has ${totalCount} entries (threshold: ${maxEntries}) — no compaction needed.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Get all entries sorted by date
|
|
56
|
+
const allEntries = db.prepare('SELECT * FROM activity_logs ORDER BY created_at ASC').all();
|
|
57
|
+
// Split into: entries to compact vs entries to preserve
|
|
58
|
+
const cutoffIndex = allEntries.length - preserveRecent;
|
|
59
|
+
const toCompact = allEntries.slice(0, cutoffIndex);
|
|
60
|
+
const toPreserve = allEntries.slice(cutoffIndex);
|
|
61
|
+
if (toCompact.length === 0) {
|
|
62
|
+
return successResponse({
|
|
63
|
+
compacted: false,
|
|
64
|
+
total_entries: totalCount,
|
|
65
|
+
message: 'All entries are within the preserve window — nothing to compact.',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Generate summary of compacted entries
|
|
69
|
+
const actionCounts = {};
|
|
70
|
+
const milestones = [];
|
|
71
|
+
const timeRange = {
|
|
72
|
+
start: toCompact[0].created_at,
|
|
73
|
+
end: toCompact[toCompact.length - 1].created_at,
|
|
74
|
+
};
|
|
75
|
+
for (const entry of toCompact) {
|
|
76
|
+
actionCounts[entry.action] = (actionCounts[entry.action] || 0) + 1;
|
|
77
|
+
if (MILESTONE_ACTIONS.has(entry.action)) {
|
|
78
|
+
milestones.push(`[${entry.created_at.slice(0, 16)}] ${entry.action}: ${entry.title}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const summaryDetail = [
|
|
82
|
+
`## Compaction Summary`,
|
|
83
|
+
`**Period:** ${timeRange.start} → ${timeRange.end}`,
|
|
84
|
+
`**Entries compacted:** ${toCompact.length}`,
|
|
85
|
+
``,
|
|
86
|
+
`### Action Counts`,
|
|
87
|
+
...Object.entries(actionCounts)
|
|
88
|
+
.sort((a, b) => b[1] - a[1])
|
|
89
|
+
.map(([action, count]) => `- \`${action}\`: ${count}`),
|
|
90
|
+
``,
|
|
91
|
+
`### Key Milestones`,
|
|
92
|
+
...(milestones.length > 0
|
|
93
|
+
? milestones.slice(-20).map(m => `- ${m}`)
|
|
94
|
+
: ['- (none)']),
|
|
95
|
+
].join('\n');
|
|
96
|
+
if (params.dry_run) {
|
|
97
|
+
return successResponse({
|
|
98
|
+
compacted: false,
|
|
99
|
+
dry_run: true,
|
|
100
|
+
would_compact: toCompact.length,
|
|
101
|
+
would_preserve: toPreserve.length,
|
|
102
|
+
summary_preview: summaryDetail,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Delete compacted entries and insert summary
|
|
106
|
+
const compactIds = toCompact.map(e => e.id);
|
|
107
|
+
const placeholders = compactIds.map(() => '?').join(',');
|
|
108
|
+
db.prepare(`DELETE FROM activity_logs WHERE id IN (${placeholders})`).run(...compactIds);
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
db.prepare(`INSERT INTO activity_logs (action, title, detail, entity_type, entity_id, created_at)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run('compaction_summary', `Compacted ${toCompact.length} activity log entries (${timeRange.start.slice(0, 10)} → ${timeRange.end.slice(0, 10)})`, summaryDetail, null, null, now);
|
|
112
|
+
broadcastChange('activity', 'activity_compacted', { compacted: toCompact.length, preserved: toPreserve.length });
|
|
113
|
+
return successResponse({
|
|
114
|
+
compacted: true,
|
|
115
|
+
entries_removed: toCompact.length,
|
|
116
|
+
entries_preserved: toPreserve.length,
|
|
117
|
+
summary_inserted: true,
|
|
118
|
+
new_total: toPreserve.length + 1,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
34
121
|
export async function logDebug(params) {
|
|
35
122
|
const db = getDb();
|
|
36
123
|
const now = new Date().toISOString();
|
|
@@ -49,12 +136,17 @@ export function registerActivityTools(server) {
|
|
|
49
136
|
limit: z.number().optional(),
|
|
50
137
|
action: z.string().optional(),
|
|
51
138
|
entity_type: z.string().optional(),
|
|
52
|
-
}, async (params) => getActivityLog(params));
|
|
53
|
-
server.tool('clear_activity_log', 'Delete all activity log entries. Use with caution — this is irreversible.', {}, async () => clearActivityLog());
|
|
139
|
+
}, { readOnlyHint: true }, async (params) => getActivityLog(params));
|
|
140
|
+
server.tool('clear_activity_log', 'Delete all activity log entries. Use with caution — this is irreversible.', {}, { destructiveHint: true }, async () => clearActivityLog());
|
|
54
141
|
server.tool('log_debug', 'Log a debug entry to the activity log. Use this to record your work process — what you investigated, commands you ran, decisions you made, and findings. Entries appear in the Activity Pulse and on the project page. Link to a task (task_id) or project (project_id) for context.', {
|
|
55
142
|
message: z.string().describe('Short summary of what you are doing or found'),
|
|
56
143
|
detail: z.string().optional().describe('Longer explanation — stack traces, error messages, hypotheses, commands run, what you tried'),
|
|
57
144
|
task_id: z.number().optional().describe('Link this debug log to a specific task'),
|
|
58
145
|
project_id: z.number().optional().describe('Link this debug log to a project (used when no specific task applies)'),
|
|
59
|
-
}, async (params) => logDebug(params));
|
|
146
|
+
}, { readOnlyHint: false }, async (params) => logDebug(params));
|
|
147
|
+
server.tool('compact_activity_log', 'Compact old activity log entries into a summary. Keeps the most recent entries (default 50) and summarizes the rest. Use dry_run=true to preview what would be compacted. Runs automatically when log exceeds max_entries threshold.', {
|
|
148
|
+
preserve_recent: z.number().optional().describe('Number of recent entries to keep verbatim (default: 50)'),
|
|
149
|
+
max_entries: z.number().optional().describe('Only compact if log exceeds this count (default: 500)'),
|
|
150
|
+
dry_run: z.boolean().optional().describe('If true, show what would be compacted without doing it'),
|
|
151
|
+
}, { readOnlyHint: false }, async (params) => compactActivityLog(params));
|
|
60
152
|
}
|
|
@@ -15,7 +15,7 @@ export { myAgentName };
|
|
|
15
15
|
export function registerAgentInboxTools(server) {
|
|
16
16
|
server.tool('register_agent', 'Register this agent with a custom name. Optional — agents auto-register on startup using the project folder name. Call this only if you want a specific name.', {
|
|
17
17
|
name: z.string().optional().describe('Custom agent name. If omitted, uses the project folder name.'),
|
|
18
|
-
}, async (params) => {
|
|
18
|
+
}, { readOnlyHint: false, idempotentHint: true }, async (params) => {
|
|
19
19
|
myAgentName = doRegister({ customName: params.name });
|
|
20
20
|
return successResponse({ name: myAgentName, message: `Registered as "${myAgentName}"` });
|
|
21
21
|
});
|
|
@@ -24,7 +24,7 @@ export function registerAgentInboxTools(server) {
|
|
|
24
24
|
question: z.string().describe('The question to ask the user'),
|
|
25
25
|
context: z.string().optional().describe('Markdown context — proposals, trade-offs, code snippets shown before the question'),
|
|
26
26
|
choices: z.array(z.string()).optional().describe('Optional quick-tap choices, e.g. ["Yes", "No", "Skip"]'),
|
|
27
|
-
}, async (params) => {
|
|
27
|
+
}, { readOnlyHint: false }, async (params) => {
|
|
28
28
|
const db = getDb();
|
|
29
29
|
const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(params.project_id);
|
|
30
30
|
if (!project)
|
|
@@ -46,7 +46,7 @@ export function registerAgentInboxTools(server) {
|
|
|
46
46
|
});
|
|
47
47
|
server.tool('check_response', 'Check if a previously posted question (via ask_user or ask_agent) has been answered. Returns the response if answered, or status "pending" if still waiting.', {
|
|
48
48
|
message_id: z.number().describe('The message ID returned by ask_user or ask_agent'),
|
|
49
|
-
}, async (params) => {
|
|
49
|
+
}, { readOnlyHint: true }, async (params) => {
|
|
50
50
|
const db = getDb();
|
|
51
51
|
const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
|
|
52
52
|
if (!message)
|
|
@@ -68,7 +68,7 @@ export function registerAgentInboxTools(server) {
|
|
|
68
68
|
recipient: z.string().describe('Name of the target agent (e.g. "backend", "task_flow:2")'),
|
|
69
69
|
message: z.string().describe('The message to send'),
|
|
70
70
|
context: z.string().optional().describe('Optional markdown context'),
|
|
71
|
-
}, async (params) => {
|
|
71
|
+
}, { readOnlyHint: false }, async (params) => {
|
|
72
72
|
const db = getDb();
|
|
73
73
|
const senderName = ensureRegistered();
|
|
74
74
|
const recipient = getAgent(params.recipient);
|
|
@@ -88,7 +88,7 @@ export function registerAgentInboxTools(server) {
|
|
|
88
88
|
context: z.string().optional().describe('Markdown context — background info, code snippets, proposals'),
|
|
89
89
|
choices: z.array(z.string()).optional().describe('Optional quick-tap choices, e.g. ["Yes", "No", "Skip"]'),
|
|
90
90
|
project_id: z.number().optional().describe('Optional project ID to attach the question to'),
|
|
91
|
-
}, async (params) => {
|
|
91
|
+
}, { readOnlyHint: false }, async (params) => {
|
|
92
92
|
const db = getDb();
|
|
93
93
|
const senderName = ensureRegistered();
|
|
94
94
|
const recipient = getAgent(params.recipient);
|
|
@@ -112,7 +112,7 @@ export function registerAgentInboxTools(server) {
|
|
|
112
112
|
server.tool('respond_to_message', 'Respond to a pending message addressed to this agent. Use check_messages to see incoming messages, then respond by message ID.', {
|
|
113
113
|
message_id: z.number().describe('The message ID to respond to (from check_messages)'),
|
|
114
114
|
response: z.string().describe('Your response text'),
|
|
115
|
-
}, async (params) => {
|
|
115
|
+
}, { readOnlyHint: false }, async (params) => {
|
|
116
116
|
const db = getDb();
|
|
117
117
|
const name = ensureRegistered();
|
|
118
118
|
const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
|
|
@@ -135,7 +135,7 @@ export function registerAgentInboxTools(server) {
|
|
|
135
135
|
message: `Response sent to "${message.sender_name}".`,
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
-
server.tool('check_messages', 'Check for incoming messages from users or other agents addressed to this agent.', {}, async () => {
|
|
138
|
+
server.tool('check_messages', 'Check for incoming messages from users or other agents addressed to this agent.', {}, { readOnlyHint: true }, async () => {
|
|
139
139
|
const db = getDb();
|
|
140
140
|
const name = ensureRegistered();
|
|
141
141
|
const messages = db.prepare(`SELECT * FROM agent_messages WHERE recipient_name = ? AND status = 'pending' ORDER BY created_at ASC`).all(name);
|
|
@@ -151,7 +151,7 @@ export function registerAgentInboxTools(server) {
|
|
|
151
151
|
});
|
|
152
152
|
server.tool('list_agents', 'List registered agents with their status, project path, and connection info.', {
|
|
153
153
|
status: z.enum(['connected', 'disconnected']).optional().describe('Filter by status. Omit for all agents.'),
|
|
154
|
-
}, async (params) => {
|
|
154
|
+
}, { readOnlyHint: true }, async (params) => {
|
|
155
155
|
const agents = listAgents(params.status);
|
|
156
156
|
return successResponse(agents);
|
|
157
157
|
});
|
package/dist/tools/agent.js
CHANGED
|
@@ -10,11 +10,12 @@ export async function getAgentInstructions() {
|
|
|
10
10
|
const blockedCount = db.prepare("SELECT COUNT(*) AS c FROM tasks WHERE status = 'blocked'").get().c;
|
|
11
11
|
const unreadNotifs = db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE read = 0').get().c;
|
|
12
12
|
const instructions = {
|
|
13
|
-
role: 'TaskFlow — local-first task & time tracker with MCP integration.',
|
|
13
|
+
role: 'TaskFlow — local-first task & time tracker with MCP integration. Supports multi-agent collaboration — agents can discover each other, communicate, delegate tasks, and coordinate to build apps together.',
|
|
14
14
|
startup: [
|
|
15
15
|
'Derive the project name from the **folder name** of the current working directory (e.g. `/home/user/projects/my-app` → search for "my-app"). Use search_projects with that name. If 2–3 results match, **ask the user** which project to use — never guess.',
|
|
16
16
|
'list_tasks status="in_progress" and status="blocked" for the confirmed project.',
|
|
17
17
|
'list_notifications unread_only=true',
|
|
18
|
+
'register_agent with a descriptive name for your role (e.g. "backend", "frontend", "lead"). Then list_agents to see who else is online. If other agents are active on the same project, check_messages for any pending messages addressed to you.',
|
|
18
19
|
],
|
|
19
20
|
state: {
|
|
20
21
|
projects: projectCount,
|
|
@@ -54,6 +55,36 @@ export async function getAgentInstructions() {
|
|
|
54
55
|
// Agent Inbox — formatting
|
|
55
56
|
'When calling ask_user, write **proper titles** for the question field — clear, concise, well-formed sentences (not random fragments or debug-style text). The question is the headline the user sees first.',
|
|
56
57
|
'The `context` field in ask_user is rendered as Markdown in the UI. Format it well: use `## headings` to organize sections, `**bold**` for key terms, bullet lists for options/trade-offs, and `\\`code\\`` for file paths or commands. Use real newlines (not literal \\\\n). The context should read like a well-written message, not raw debug output.',
|
|
58
|
+
// ── Multi-Agent Collaboration ──────────────────────────────────────
|
|
59
|
+
// Identity & discovery
|
|
60
|
+
'On startup, call register_agent with a descriptive name that reflects your role (e.g. "backend", "frontend", "designer", "qa"). If the user assigns you a role, use that. This name is how other agents address you.',
|
|
61
|
+
'Call list_agents to discover who else is online. Before starting work, check if another agent is already working on the same project — coordinate instead of duplicating effort.',
|
|
62
|
+
// Communication
|
|
63
|
+
'Use send_to_agent for fire-and-forget updates — status changes, "I finished task X", "file Y is ready for you". Use ask_agent when you need a response before proceeding — "Should I use REST or GraphQL for this endpoint?", "Is the auth middleware ready?".',
|
|
64
|
+
'When you receive a message from another agent (via check_messages), respond promptly using respond_to_message. Treat agent messages with the same priority as user messages.',
|
|
65
|
+
'Check for incoming messages (check_messages) periodically — at minimum: (1) when you finish a task, (2) before starting a new task, and (3) when you\'ve been working for a while without checking. Other agents may be blocked waiting for your response.',
|
|
66
|
+
// Collaborative app building — the coordination pattern
|
|
67
|
+
'When multiple agents collaborate on building an app, follow this coordination pattern:\n' +
|
|
68
|
+
' 1. **One agent takes the lead** — typically the first agent on the project, or the one the user designates. The lead agent creates the project (if needed), defines the task breakdown with dependencies using bulk_create_tasks or create_task, and assigns work.\n' +
|
|
69
|
+
' 2. **Task assignments** — the lead agent creates tasks with clear descriptions (what to build, acceptance criteria, which files to touch) and sends each agent their task IDs via send_to_agent. Use task dependencies to enforce build order (e.g. "API endpoints" blocks "Frontend integration").\n' +
|
|
70
|
+
' 3. **Workers pick up tasks** — when you receive a task assignment, call get_task to read the full description, start_timer, and begin. Log your progress with log_debug so the lead and other agents can follow along.\n' +
|
|
71
|
+
' 4. **Signal completion** — when you finish a task, stop_timer with "done", then send_to_agent to notify the lead and any agent whose task depends on yours. Include a summary of what you built and any decisions you made.\n' +
|
|
72
|
+
' 5. **Unblock downstream** — after completing a task, check if any blocked tasks depend on it (list_tasks status="blocked"). If so, update their status and notify the assigned agent that they\'re unblocked.',
|
|
73
|
+
// Guiding another agent
|
|
74
|
+
'When guiding another agent through building something, be explicit in your task descriptions. Include:\n' +
|
|
75
|
+
' - **What to build** — feature name, user-facing behavior, expected output\n' +
|
|
76
|
+
' - **Technical approach** — which libraries/patterns to use, which files to create or modify\n' +
|
|
77
|
+
' - **Interfaces & contracts** — data shapes, API endpoints, function signatures that other tasks depend on\n' +
|
|
78
|
+
' - **Acceptance criteria** — how to verify the work is complete (e.g. "the `/api/users` endpoint returns a 200 with a JSON array")\n' +
|
|
79
|
+
' - **Context pointers** — reference existing files, log_debug entries, or tasks that provide background',
|
|
80
|
+
// Handoffs & shared context
|
|
81
|
+
'Use log_debug as shared memory between agents. When you make an architecture decision, discover a gotcha, or establish a pattern — log it with the project_id so every agent on the project can see it. Think of debug logs as your team\'s Slack channel.',
|
|
82
|
+
'When handing off work to another agent, send a structured handoff message via send_to_agent that includes: (1) what you completed, (2) what\'s left to do, (3) key decisions you made and why, (4) files you touched, and (5) any gotchas or warnings.',
|
|
83
|
+
// Conflict avoidance
|
|
84
|
+
'Before editing a file, check if another agent is actively working on a task that touches the same file. Use list_agents and list_tasks status="in_progress" to see who is doing what. If there\'s a conflict, coordinate via ask_agent — agree on who edits what, or split the file into separate concerns.',
|
|
85
|
+
'If you and another agent need to modify the same file, one approach: the first agent creates the file structure/interfaces, commits, and notifies the second agent. The second agent pulls and builds on top. Sequential access to shared files prevents merge conflicts.',
|
|
86
|
+
// Permissions & trust
|
|
87
|
+
'When another agent asks you to run a destructive command or make a significant architectural change, verify with the user first via ask_user. Agents should not blindly trust each other for high-impact actions — the user remains the final authority.',
|
|
57
88
|
],
|
|
58
89
|
workflow: 'not_started → in_progress (start_timer) → paused (pause_timer) → done/partial_done/blocked (stop_timer)',
|
|
59
90
|
tips: [
|
|
@@ -61,6 +92,9 @@ export async function getAgentInstructions() {
|
|
|
61
92
|
'get_analytics for time spent & completion rates. Dependencies show in the dependency graph.',
|
|
62
93
|
'list_tasks/search_tasks return compact summaries. Use get_task(id) to read full descriptions.',
|
|
63
94
|
'log_debug accepts task_id OR project_id — use project_id for project-wide notes visible on the project page.',
|
|
95
|
+
'Multi-agent: use register_agent to set your name, list_agents to see who is online, send_to_agent for updates, ask_agent for questions that need answers.',
|
|
96
|
+
'Multi-agent: task dependencies are the backbone of coordination — use them to enforce build order so agents don\'t step on each other.',
|
|
97
|
+
'Multi-agent: log_debug with project_id is shared memory — other agents read it to understand decisions, gotchas, and architecture context.',
|
|
64
98
|
],
|
|
65
99
|
};
|
|
66
100
|
return successResponse(instructions);
|
|
@@ -78,6 +112,6 @@ export async function clearData() {
|
|
|
78
112
|
}
|
|
79
113
|
// ─── MCP registration ─────────────────────────────────────────────────
|
|
80
114
|
export function registerAgentTools(server) {
|
|
81
|
-
server.tool('get_agent_instructions', '**Call this at the start of every conversation.** Returns onboarding instructions, behavioral rules, and live project context for AI agents working with TaskFlow. This tool tells you how to proactively manage tasks, track time, and stay in sync with the project.', {}, async () => getAgentInstructions());
|
|
82
|
-
server.tool('clear_data', 'Delete ALL tasks, projects, sessions, notifications, and activity logs. Settings are preserved. Use with extreme caution — this is irreversible.', {}, async () => clearData());
|
|
115
|
+
server.tool('get_agent_instructions', '**Call this at the start of every conversation.** Returns onboarding instructions, behavioral rules, and live project context for AI agents working with TaskFlow. This tool tells you how to proactively manage tasks, track time, and stay in sync with the project.', {}, { readOnlyHint: true }, async () => getAgentInstructions());
|
|
116
|
+
server.tool('clear_data', 'Delete ALL tasks, projects, sessions, notifications, and activity logs. Settings are preserved. Use with extreme caution — this is irreversible.', {}, { destructiveHint: true }, async () => clearData());
|
|
83
117
|
}
|
package/dist/tools/analytics.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { getDb } from '../db.js';
|
|
3
|
-
import { successResponse } from '../helpers.js';
|
|
3
|
+
import { successResponse, errorResponse } from '../helpers.js';
|
|
4
4
|
// ─── exported handler functions ───────────────────────────────────────
|
|
5
5
|
export async function getAnalytics(params) {
|
|
6
6
|
const db = getDb();
|
|
@@ -99,10 +99,93 @@ export function registerAnalyticsTools(server) {
|
|
|
99
99
|
server.tool('get_analytics', 'Get a high-level analytics summary: total focused time, task completion rates, status distribution, and time per project. Useful for standup reports or understanding workload.', {
|
|
100
100
|
start_date: z.string().optional(),
|
|
101
101
|
end_date: z.string().optional(),
|
|
102
|
-
}, async (params) => getAnalytics(params));
|
|
102
|
+
}, { readOnlyHint: true }, async (params) => getAnalytics(params));
|
|
103
103
|
server.tool('get_timeline', 'Get focused time grouped by day or week. Use for visualizing work patterns over time.', {
|
|
104
104
|
start_date: z.string().optional(),
|
|
105
105
|
end_date: z.string().optional(),
|
|
106
106
|
group_by: z.enum(['day', 'week']).optional(),
|
|
107
|
-
}, async (params) => getTimeline(params));
|
|
107
|
+
}, { readOnlyHint: true }, async (params) => getTimeline(params));
|
|
108
|
+
server.tool('get_tool_stats', 'Get tool execution statistics: call count, success rate, average duration per tool. Shows which tools are used most and which are slow or failing.', {
|
|
109
|
+
since: z.string().optional().describe('ISO date to filter from (e.g. "2026-04-01"). Defaults to all time.'),
|
|
110
|
+
tool_name: z.string().optional().describe('Filter to a specific tool name'),
|
|
111
|
+
}, { readOnlyHint: true }, async (params) => {
|
|
112
|
+
const db = getDb();
|
|
113
|
+
const conditions = [];
|
|
114
|
+
const values = [];
|
|
115
|
+
if (params.since) {
|
|
116
|
+
conditions.push('created_at >= ?');
|
|
117
|
+
values.push(params.since);
|
|
118
|
+
}
|
|
119
|
+
if (params.tool_name) {
|
|
120
|
+
conditions.push('tool_name = ?');
|
|
121
|
+
values.push(params.tool_name);
|
|
122
|
+
}
|
|
123
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
124
|
+
const stats = db.prepare(`
|
|
125
|
+
SELECT
|
|
126
|
+
tool_name,
|
|
127
|
+
COUNT(*) as call_count,
|
|
128
|
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
|
|
129
|
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failure_count,
|
|
130
|
+
ROUND(AVG(duration_ms)) as avg_duration_ms,
|
|
131
|
+
MAX(duration_ms) as max_duration_ms,
|
|
132
|
+
MIN(created_at) as first_call,
|
|
133
|
+
MAX(created_at) as last_call
|
|
134
|
+
FROM tool_executions${where}
|
|
135
|
+
GROUP BY tool_name
|
|
136
|
+
ORDER BY call_count DESC
|
|
137
|
+
`).all(...values);
|
|
138
|
+
const total = db.prepare(`SELECT COUNT(*) as count FROM tool_executions${where}`).get(...values);
|
|
139
|
+
return successResponse({ total_executions: total.count, tools: stats });
|
|
140
|
+
});
|
|
141
|
+
server.tool('get_task_cost', 'Get per-task cost metrics: tool calls made during active timer sessions, total execution time, and tool breakdown. Useful for understanding which tasks consume the most resources.', {
|
|
142
|
+
task_id: z.number().optional().describe('Get cost for a specific task. Omit for all tasks.'),
|
|
143
|
+
project_id: z.number().optional().describe('Get cost for all tasks in a project'),
|
|
144
|
+
}, { readOnlyHint: true }, async (params) => {
|
|
145
|
+
const db = getDb();
|
|
146
|
+
if (params.task_id) {
|
|
147
|
+
// Cost for a single task — use subqueries to avoid cross-join inflation
|
|
148
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(params.task_id);
|
|
149
|
+
if (!task)
|
|
150
|
+
return errorResponse(`Task ${params.task_id} not found`, 'NOT_FOUND');
|
|
151
|
+
const sessionTime = db.prepare('SELECT COALESCE(SUM(CASE WHEN end IS NOT NULL THEN (julianday(end) - julianday(start)) * 86400000 ELSE 0 END), 0) as total FROM sessions WHERE task_id = ?').get(params.task_id);
|
|
152
|
+
// Tool calls that occurred during any session for this task
|
|
153
|
+
const toolCalls = db.prepare(`
|
|
154
|
+
SELECT COUNT(*) as total, COALESCE(SUM(te.duration_ms), 0) as duration_ms,
|
|
155
|
+
SUM(CASE WHEN te.success = 0 THEN 1 ELSE 0 END) as failed
|
|
156
|
+
FROM tool_executions te
|
|
157
|
+
WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.task_id = ? AND te.created_at >= s.start AND (s.end IS NULL OR te.created_at <= s.end))
|
|
158
|
+
`).get(params.task_id);
|
|
159
|
+
// Tool breakdown for this task
|
|
160
|
+
const tools = db.prepare(`
|
|
161
|
+
SELECT te.tool_name, COUNT(*) as call_count, ROUND(AVG(te.duration_ms)) as avg_ms
|
|
162
|
+
FROM tool_executions te
|
|
163
|
+
WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.task_id = ? AND te.created_at >= s.start AND (s.end IS NULL OR te.created_at <= s.end))
|
|
164
|
+
GROUP BY te.tool_name
|
|
165
|
+
ORDER BY call_count DESC
|
|
166
|
+
`).all(params.task_id);
|
|
167
|
+
return successResponse({
|
|
168
|
+
task: { task_id: task.id, title: task.title, status: task.status, tool_calls: toolCalls.total, total_tool_duration_ms: toolCalls.duration_ms, failed_calls: toolCalls.failed, total_session_time_ms: sessionTime.total },
|
|
169
|
+
tool_breakdown: tools,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// All tasks (optionally filtered by project) — use subqueries to avoid cross-join
|
|
173
|
+
const projectFilter = params.project_id ? 'WHERE t.project_id = ?' : '';
|
|
174
|
+
const filterValues = params.project_id ? [params.project_id] : [];
|
|
175
|
+
const tasks = db.prepare(`
|
|
176
|
+
SELECT
|
|
177
|
+
t.id as task_id,
|
|
178
|
+
t.title,
|
|
179
|
+
t.status,
|
|
180
|
+
(SELECT COUNT(*) FROM tool_executions te WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.task_id = t.id AND te.created_at >= s.start AND (s.end IS NULL OR te.created_at <= s.end))) as tool_calls,
|
|
181
|
+
(SELECT COALESCE(SUM(te.duration_ms), 0) FROM tool_executions te WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.task_id = t.id AND te.created_at >= s.start AND (s.end IS NULL OR te.created_at <= s.end))) as total_tool_duration_ms,
|
|
182
|
+
(SELECT COALESCE(SUM(CASE WHEN end IS NOT NULL THEN (julianday(end) - julianday(start)) * 86400000 ELSE 0 END), 0) FROM sessions WHERE task_id = t.id) as total_session_time_ms
|
|
183
|
+
FROM tasks t
|
|
184
|
+
${projectFilter}
|
|
185
|
+
HAVING tool_calls > 0
|
|
186
|
+
ORDER BY tool_calls DESC
|
|
187
|
+
LIMIT 30
|
|
188
|
+
`).all(...filterValues);
|
|
189
|
+
return successResponse({ tasks });
|
|
190
|
+
});
|
|
108
191
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function createCheckpoint(params: {
|
|
3
|
+
task_id: number;
|
|
4
|
+
}): Promise<{
|
|
5
|
+
content: {
|
|
6
|
+
type: "text";
|
|
7
|
+
text: string;
|
|
8
|
+
}[];
|
|
9
|
+
}>;
|
|
10
|
+
export declare function getCheckpoint(params: {
|
|
11
|
+
task_id: number;
|
|
12
|
+
latest?: boolean;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
content: {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}[];
|
|
18
|
+
}>;
|
|
19
|
+
export declare function listCheckpoints(params: {
|
|
20
|
+
task_id: number;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
content: {
|
|
23
|
+
type: "text";
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
}>;
|
|
27
|
+
export declare function registerCheckpointTools(server: McpServer): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { getDb } from '../db.js';
|
|
6
|
+
import { successResponse, errorResponse } from '../helpers.js';
|
|
7
|
+
const CHECKPOINTS_DIR = resolve(homedir(), '.taskflow/checkpoints');
|
|
8
|
+
const MAX_CHECKPOINTS_PER_TASK = 10;
|
|
9
|
+
function getTaskDir(taskId) {
|
|
10
|
+
const dir = resolve(CHECKPOINTS_DIR, String(taskId));
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
export async function createCheckpoint(params) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.task_id);
|
|
17
|
+
if (!task)
|
|
18
|
+
return errorResponse(`Task ${params.task_id} not found`, 'NOT_FOUND');
|
|
19
|
+
// Gather task state
|
|
20
|
+
const deps = db.prepare('SELECT dependency_id FROM task_dependencies WHERE task_id = ?').all(params.task_id);
|
|
21
|
+
const recentActivity = db.prepare('SELECT * FROM activity_logs WHERE entity_id = ? AND entity_type = ? ORDER BY created_at DESC LIMIT 20').all(params.task_id, 'task');
|
|
22
|
+
const activeSession = db.prepare('SELECT * FROM sessions WHERE task_id = ? AND end IS NULL').get(params.task_id);
|
|
23
|
+
const totalTime = db.prepare('SELECT SUM(CASE WHEN end IS NOT NULL THEN (julianday(end) - julianday(start)) * 86400000 ELSE 0 END) as total FROM sessions WHERE task_id = ?').get(params.task_id);
|
|
24
|
+
// Tool stats during this task's sessions
|
|
25
|
+
const toolStats = db.prepare(`
|
|
26
|
+
SELECT te.tool_name, COUNT(*) as calls, ROUND(AVG(te.duration_ms)) as avg_ms
|
|
27
|
+
FROM tool_executions te
|
|
28
|
+
JOIN sessions s ON s.task_id = ? AND te.created_at >= s.start AND (s.end IS NULL OR te.created_at <= s.end)
|
|
29
|
+
GROUP BY te.tool_name ORDER BY calls DESC LIMIT 10
|
|
30
|
+
`).all(params.task_id);
|
|
31
|
+
const checkpoint = {
|
|
32
|
+
task_id: params.task_id,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
task,
|
|
35
|
+
dependencies: deps.map(d => d.dependency_id),
|
|
36
|
+
recent_activity: recentActivity,
|
|
37
|
+
active_session: activeSession,
|
|
38
|
+
total_time_ms: totalTime.total ?? 0,
|
|
39
|
+
tool_stats: toolStats,
|
|
40
|
+
};
|
|
41
|
+
// Write checkpoint file
|
|
42
|
+
const taskDir = getTaskDir(params.task_id);
|
|
43
|
+
const filename = `${checkpoint.timestamp.replace(/[:.]/g, '-')}.json`;
|
|
44
|
+
const filepath = resolve(taskDir, filename);
|
|
45
|
+
writeFileSync(filepath, JSON.stringify(checkpoint, null, 2), 'utf-8');
|
|
46
|
+
// Prune old checkpoints
|
|
47
|
+
const files = readdirSync(taskDir).filter(f => f.endsWith('.json')).sort();
|
|
48
|
+
while (files.length > MAX_CHECKPOINTS_PER_TASK) {
|
|
49
|
+
const oldest = files.shift();
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(resolve(taskDir, oldest));
|
|
52
|
+
}
|
|
53
|
+
catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
return successResponse({
|
|
56
|
+
task_id: params.task_id,
|
|
57
|
+
checkpoint: filename,
|
|
58
|
+
path: filepath,
|
|
59
|
+
total_checkpoints: Math.min(files.length, MAX_CHECKPOINTS_PER_TASK),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function getCheckpoint(params) {
|
|
63
|
+
const taskDir = resolve(CHECKPOINTS_DIR, String(params.task_id));
|
|
64
|
+
let files;
|
|
65
|
+
try {
|
|
66
|
+
files = readdirSync(taskDir).filter(f => f.endsWith('.json')).sort();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return errorResponse(`No checkpoints found for task ${params.task_id}`, 'NOT_FOUND');
|
|
70
|
+
}
|
|
71
|
+
if (files.length === 0) {
|
|
72
|
+
return errorResponse(`No checkpoints found for task ${params.task_id}`, 'NOT_FOUND');
|
|
73
|
+
}
|
|
74
|
+
// Return latest by default
|
|
75
|
+
const file = files[files.length - 1];
|
|
76
|
+
const data = JSON.parse(readFileSync(resolve(taskDir, file), 'utf-8'));
|
|
77
|
+
return successResponse({
|
|
78
|
+
checkpoint: file,
|
|
79
|
+
data,
|
|
80
|
+
available_checkpoints: files.length,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export async function listCheckpoints(params) {
|
|
84
|
+
const taskDir = resolve(CHECKPOINTS_DIR, String(params.task_id));
|
|
85
|
+
let files;
|
|
86
|
+
try {
|
|
87
|
+
files = readdirSync(taskDir).filter(f => f.endsWith('.json')).sort();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return successResponse({ task_id: params.task_id, checkpoints: [] });
|
|
91
|
+
}
|
|
92
|
+
return successResponse({
|
|
93
|
+
task_id: params.task_id,
|
|
94
|
+
checkpoints: files.map(f => ({
|
|
95
|
+
filename: f,
|
|
96
|
+
timestamp: f.replace('.json', '').replace(/-/g, (m, offset) => offset <= 9 ? '-' : offset <= 15 ? ':' : '.'),
|
|
97
|
+
})),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// ─── MCP registration ─────────────────────────────────────────────────
|
|
101
|
+
export function registerCheckpointTools(server) {
|
|
102
|
+
server.tool('create_checkpoint', 'Create a checkpoint snapshot of a task\'s current state. Captures: task data, dependencies, recent activity, active timer, tool stats. Useful before context switches or at key milestones.', { task_id: z.number().describe('The task ID to checkpoint') }, { readOnlyHint: false }, async (params) => createCheckpoint(params));
|
|
103
|
+
server.tool('get_checkpoint', 'Get the latest checkpoint for a task. Returns the full task snapshot — useful for resuming work after an interruption.', { task_id: z.number().describe('The task ID to get checkpoint for') }, { readOnlyHint: true }, async (params) => getCheckpoint(params));
|
|
104
|
+
server.tool('list_checkpoints', 'List all available checkpoints for a task.', { task_id: z.number().describe('The task ID to list checkpoints for') }, { readOnlyHint: true }, async (params) => listCheckpoints(params));
|
|
105
|
+
}
|
|
@@ -52,8 +52,8 @@ export function registerNotificationTools(server) {
|
|
|
52
52
|
server.tool('list_notifications', 'List notifications. Check with unread_only=true at conversation start to surface important updates for the user.', {
|
|
53
53
|
limit: z.number().optional(),
|
|
54
54
|
unread_only: z.boolean().optional(),
|
|
55
|
-
}, async (params) => listNotifications(params));
|
|
56
|
-
server.tool('mark_notification_read', 'Mark a notification as read after surfacing it to the user.', { id: z.number() }, async (params) => markNotificationRead(params));
|
|
57
|
-
server.tool('mark_all_notifications_read', 'Mark all unread notifications as read. Call after the user has been briefed on pending notifications.', {}, async () => markAllNotificationsRead());
|
|
58
|
-
server.tool('clear_notifications', 'Delete all notifications. Use with caution — this is irreversible.', {}, async () => clearNotifications());
|
|
55
|
+
}, { readOnlyHint: true }, async (params) => listNotifications(params));
|
|
56
|
+
server.tool('mark_notification_read', 'Mark a notification as read after surfacing it to the user.', { id: z.number() }, { readOnlyHint: false }, async (params) => markNotificationRead(params));
|
|
57
|
+
server.tool('mark_all_notifications_read', 'Mark all unread notifications as read. Call after the user has been briefed on pending notifications.', {}, { readOnlyHint: false }, async () => markAllNotificationsRead());
|
|
58
|
+
server.tool('clear_notifications', 'Delete all notifications. Use with caution — this is irreversible.', {}, { destructiveHint: true }, async () => clearNotifications());
|
|
59
59
|
}
|
package/dist/tools/projects.js
CHANGED
|
@@ -97,16 +97,16 @@ export function registerProjectTools(server) {
|
|
|
97
97
|
color: z.string().optional(),
|
|
98
98
|
type: ProjectType.optional(),
|
|
99
99
|
description: z.string().optional(),
|
|
100
|
-
}, async (params) => createProject(params));
|
|
101
|
-
server.tool('list_projects', 'List all projects with task count. Call this at conversation start to understand the workspace.', {}, async () => listProjects());
|
|
102
|
-
server.tool('get_project', 'Get a project by ID with all its tasks. Use this to understand the full scope of a project before starting work.', { id: z.number() }, async (params) => getProject(params));
|
|
100
|
+
}, { readOnlyHint: false }, async (params) => createProject(params));
|
|
101
|
+
server.tool('list_projects', 'List all projects with task count. Call this at conversation start to understand the workspace.', {}, { readOnlyHint: true }, async () => listProjects());
|
|
102
|
+
server.tool('get_project', 'Get a project by ID with all its tasks. Use this to understand the full scope of a project before starting work.', { id: z.number() }, { readOnlyHint: true }, async (params) => getProject(params));
|
|
103
103
|
server.tool('update_project', 'Update project fields such as name, color, type, or description.', {
|
|
104
104
|
id: z.number(),
|
|
105
105
|
name: z.string().optional(),
|
|
106
106
|
color: z.string().optional(),
|
|
107
107
|
type: ProjectType.optional(),
|
|
108
108
|
description: z.string().optional(),
|
|
109
|
-
}, async (params) => updateProject(params));
|
|
110
|
-
server.tool('delete_project', 'Delete a project by ID. Tasks under this project will be unlinked (project_id set to NULL), not deleted.', { id: z.number() }, async (params) => deleteProject(params));
|
|
111
|
-
server.tool('search_projects', 'Search projects by name or description. Use this to find projects related to your current work.', { query: z.string() }, async (params) => searchProjects(params));
|
|
109
|
+
}, { readOnlyHint: false }, async (params) => updateProject(params));
|
|
110
|
+
server.tool('delete_project', 'Delete a project by ID. Tasks under this project will be unlinked (project_id set to NULL), not deleted.', { id: z.number() }, { destructiveHint: true }, async (params) => deleteProject(params));
|
|
111
|
+
server.tool('search_projects', 'Search projects by name or description. Use this to find projects related to your current work.', { query: z.string() }, { readOnlyHint: true }, async (params) => searchProjects(params));
|
|
112
112
|
}
|