handoff-mcp-server 0.2.0 → 0.4.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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/check_criterion.rs +68 -0
- package/src/mcp/handlers/dashboard.rs +7 -9
- package/src/mcp/handlers/get_task.rs +43 -0
- package/src/mcp/handlers/import_context.rs +33 -4
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +7 -0
- package/src/mcp/handlers/mod.rs +9 -0
- package/src/mcp/handlers/refer.rs +152 -0
- package/src/mcp/handlers/referrals.rs +55 -0
- package/src/mcp/handlers/update_task.rs +57 -9
- package/src/mcp/tools.rs +174 -3
- package/src/storage/mod.rs +10 -0
- package/src/storage/referrals.rs +210 -0
- package/src/storage/tasks.rs +180 -1
- package/tests/mcp_protocol.rs +4 -0
- package/tests/storage_tasks.rs +37 -0
- package/tests/tool_import_context.rs +66 -0
- package/tests/tool_referrals.rs +442 -0
- package/tests/tool_tasks.rs +357 -0
package/src/storage/tasks.rs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
use std::collections::{HashMap, HashSet};
|
|
1
2
|
use std::path::{Path, PathBuf};
|
|
2
3
|
|
|
3
4
|
use anyhow::{Context, Result};
|
|
5
|
+
use chrono::Utc;
|
|
4
6
|
use serde::{Deserialize, Serialize};
|
|
5
7
|
|
|
6
8
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -23,6 +25,26 @@ pub struct TaskData {
|
|
|
23
25
|
pub links: Vec<String>,
|
|
24
26
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
25
27
|
pub done_criteria: Vec<DoneCriterion>,
|
|
28
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
+
pub schedule: Option<Schedule>,
|
|
30
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
+
pub dependencies: Vec<String>,
|
|
32
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
|
+
pub order: Option<u32>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
37
|
+
pub struct Schedule {
|
|
38
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
39
|
+
pub start_date: Option<String>,
|
|
40
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
41
|
+
pub due_date: Option<String>,
|
|
42
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
43
|
+
pub estimate_hours: Option<f64>,
|
|
44
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
45
|
+
pub actual_hours: Option<f64>,
|
|
46
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
47
|
+
pub milestone: Option<String>,
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -37,6 +59,12 @@ pub struct TaskIndex {
|
|
|
37
59
|
pub id: String,
|
|
38
60
|
pub title: String,
|
|
39
61
|
pub status: String,
|
|
62
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
63
|
+
pub schedule: Option<Schedule>,
|
|
64
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
65
|
+
pub dependencies: Vec<String>,
|
|
66
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
|
+
pub order: Option<u32>,
|
|
40
68
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
41
69
|
pub children: Vec<TaskIndex>,
|
|
42
70
|
}
|
|
@@ -45,6 +73,14 @@ pub struct TaskIndex {
|
|
|
45
73
|
pub struct TaskSummary {
|
|
46
74
|
pub total: u32,
|
|
47
75
|
pub by_status: std::collections::HashMap<String, u32>,
|
|
76
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
77
|
+
pub total_estimate_hours: Option<f64>,
|
|
78
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
79
|
+
pub total_actual_hours: Option<f64>,
|
|
80
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
81
|
+
pub completion_rate: Option<f64>,
|
|
82
|
+
#[serde(default)]
|
|
83
|
+
pub overdue_count: u32,
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
const VALID_STATUSES: &[&str] = &[
|
|
@@ -56,10 +92,28 @@ const VALID_STATUSES: &[&str] = &[
|
|
|
56
92
|
"skipped",
|
|
57
93
|
];
|
|
58
94
|
|
|
95
|
+
const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
|
|
96
|
+
|
|
59
97
|
pub fn is_valid_status(status: &str) -> bool {
|
|
60
98
|
VALID_STATUSES.contains(&status)
|
|
61
99
|
}
|
|
62
100
|
|
|
101
|
+
pub fn is_valid_priority(priority: &str) -> bool {
|
|
102
|
+
VALID_PRIORITIES.contains(&priority)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pub fn validate_priority(priority: Option<&str>) -> Result<()> {
|
|
106
|
+
if let Some(p) = priority {
|
|
107
|
+
if !is_valid_priority(p) {
|
|
108
|
+
anyhow::bail!(
|
|
109
|
+
"Invalid priority: '{p}'. Must be one of: {}",
|
|
110
|
+
VALID_PRIORITIES.join(", ")
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
|
|
63
117
|
pub fn is_terminal_status(status: &str) -> bool {
|
|
64
118
|
status == "done" || status == "skipped"
|
|
65
119
|
}
|
|
@@ -251,9 +305,17 @@ pub fn build_task_index(
|
|
|
251
305
|
let mut tree = Vec::new();
|
|
252
306
|
let mut summary = TaskSummary {
|
|
253
307
|
total: 0,
|
|
254
|
-
by_status:
|
|
308
|
+
by_status: HashMap::new(),
|
|
309
|
+
total_estimate_hours: None,
|
|
310
|
+
total_actual_hours: None,
|
|
311
|
+
completion_rate: None,
|
|
312
|
+
overdue_count: 0,
|
|
255
313
|
};
|
|
256
314
|
let mut done_count: u32 = 0;
|
|
315
|
+
let mut estimate_sum: f64 = 0.0;
|
|
316
|
+
let mut actual_sum: f64 = 0.0;
|
|
317
|
+
let mut has_hours = false;
|
|
318
|
+
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
257
319
|
|
|
258
320
|
build_index_recursive(
|
|
259
321
|
tasks_dir,
|
|
@@ -261,17 +323,37 @@ pub fn build_task_index(
|
|
|
261
323
|
&mut summary,
|
|
262
324
|
&mut done_count,
|
|
263
325
|
done_task_limit,
|
|
326
|
+
&mut estimate_sum,
|
|
327
|
+
&mut actual_sum,
|
|
328
|
+
&mut has_hours,
|
|
329
|
+
&today,
|
|
264
330
|
)?;
|
|
265
331
|
|
|
332
|
+
if has_hours {
|
|
333
|
+
summary.total_estimate_hours = Some(estimate_sum);
|
|
334
|
+
summary.total_actual_hours = Some(actual_sum);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if summary.total > 0 {
|
|
338
|
+
let done = *summary.by_status.get("done").unwrap_or(&0) as f64;
|
|
339
|
+
let skipped = *summary.by_status.get("skipped").unwrap_or(&0) as f64;
|
|
340
|
+
summary.completion_rate = Some((done + skipped) / summary.total as f64);
|
|
341
|
+
}
|
|
342
|
+
|
|
266
343
|
Ok((tree, summary))
|
|
267
344
|
}
|
|
268
345
|
|
|
346
|
+
#[allow(clippy::too_many_arguments)]
|
|
269
347
|
fn build_index_recursive(
|
|
270
348
|
dir: &Path,
|
|
271
349
|
tree: &mut Vec<TaskIndex>,
|
|
272
350
|
summary: &mut TaskSummary,
|
|
273
351
|
done_count: &mut u32,
|
|
274
352
|
done_task_limit: u32,
|
|
353
|
+
estimate_sum: &mut f64,
|
|
354
|
+
actual_sum: &mut f64,
|
|
355
|
+
has_hours: &mut bool,
|
|
356
|
+
today: &str,
|
|
275
357
|
) -> Result<()> {
|
|
276
358
|
if !dir.exists() {
|
|
277
359
|
return Ok(());
|
|
@@ -298,6 +380,22 @@ fn build_index_recursive(
|
|
|
298
380
|
summary.total += 1;
|
|
299
381
|
*summary.by_status.entry(status.clone()).or_insert(0) += 1;
|
|
300
382
|
|
|
383
|
+
if let Some(ref sched) = data.schedule {
|
|
384
|
+
if let Some(est) = sched.estimate_hours {
|
|
385
|
+
*estimate_sum += est;
|
|
386
|
+
*has_hours = true;
|
|
387
|
+
}
|
|
388
|
+
if let Some(act) = sched.actual_hours {
|
|
389
|
+
*actual_sum += act;
|
|
390
|
+
*has_hours = true;
|
|
391
|
+
}
|
|
392
|
+
if let Some(ref due) = sched.due_date {
|
|
393
|
+
if !is_terminal_status(&status) && due.as_str() < today {
|
|
394
|
+
summary.overdue_count += 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
301
399
|
if is_terminal_status(&status) {
|
|
302
400
|
*done_count += 1;
|
|
303
401
|
if *done_count > done_task_limit {
|
|
@@ -312,12 +410,19 @@ fn build_index_recursive(
|
|
|
312
410
|
summary,
|
|
313
411
|
done_count,
|
|
314
412
|
done_task_limit,
|
|
413
|
+
estimate_sum,
|
|
414
|
+
actual_sum,
|
|
415
|
+
has_hours,
|
|
416
|
+
today,
|
|
315
417
|
)?;
|
|
316
418
|
|
|
317
419
|
tree.push(TaskIndex {
|
|
318
420
|
id: data.id,
|
|
319
421
|
title: data.title,
|
|
320
422
|
status,
|
|
423
|
+
schedule: data.schedule,
|
|
424
|
+
dependencies: data.dependencies,
|
|
425
|
+
order: data.order,
|
|
321
426
|
children,
|
|
322
427
|
});
|
|
323
428
|
}
|
|
@@ -325,6 +430,80 @@ fn build_index_recursive(
|
|
|
325
430
|
Ok(())
|
|
326
431
|
}
|
|
327
432
|
|
|
433
|
+
pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String]) -> Result<()> {
|
|
434
|
+
let dep_graph = build_dependency_graph(tasks_dir)?;
|
|
435
|
+
|
|
436
|
+
let mut graph = dep_graph;
|
|
437
|
+
graph.insert(task_id.to_string(), new_deps.to_vec());
|
|
438
|
+
|
|
439
|
+
let mut visited = HashSet::new();
|
|
440
|
+
let mut stack = HashSet::new();
|
|
441
|
+
|
|
442
|
+
if has_cycle(&graph, task_id, &mut visited, &mut stack) {
|
|
443
|
+
anyhow::bail!(
|
|
444
|
+
"Circular dependency detected: setting dependencies {:?} on task {task_id} would create a cycle",
|
|
445
|
+
new_deps
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
Ok(())
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
|
|
453
|
+
let mut graph = HashMap::new();
|
|
454
|
+
build_dep_graph_recursive(tasks_dir, &mut graph)?;
|
|
455
|
+
Ok(graph)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn build_dep_graph_recursive(dir: &Path, graph: &mut HashMap<String, Vec<String>>) -> Result<()> {
|
|
459
|
+
if !dir.exists() {
|
|
460
|
+
return Ok(());
|
|
461
|
+
}
|
|
462
|
+
for entry in std::fs::read_dir(dir)? {
|
|
463
|
+
let entry = entry?;
|
|
464
|
+
if !entry.file_type()?.is_dir() {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
468
|
+
if name.starts_with('.') || name.starts_with('_') {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
let task_dir = entry.path();
|
|
472
|
+
if let Some((data, _)) = read_task(&task_dir)? {
|
|
473
|
+
graph.insert(data.id.clone(), data.dependencies.clone());
|
|
474
|
+
build_dep_graph_recursive(&task_dir, graph)?;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
Ok(())
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn has_cycle(
|
|
481
|
+
graph: &HashMap<String, Vec<String>>,
|
|
482
|
+
node: &str,
|
|
483
|
+
visited: &mut HashSet<String>,
|
|
484
|
+
stack: &mut HashSet<String>,
|
|
485
|
+
) -> bool {
|
|
486
|
+
if stack.contains(node) {
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
if visited.contains(node) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
visited.insert(node.to_string());
|
|
493
|
+
stack.insert(node.to_string());
|
|
494
|
+
|
|
495
|
+
if let Some(deps) = graph.get(node) {
|
|
496
|
+
for dep in deps {
|
|
497
|
+
if has_cycle(graph, dep, visited, stack) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
stack.remove(node);
|
|
504
|
+
false
|
|
505
|
+
}
|
|
506
|
+
|
|
328
507
|
pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
|
|
329
508
|
for criterion in &data.done_criteria {
|
|
330
509
|
if !criterion.checked {
|
package/tests/mcp_protocol.rs
CHANGED
|
@@ -55,6 +55,10 @@ fn tools_list_returns_all_tools() {
|
|
|
55
55
|
"handoff_get_config",
|
|
56
56
|
"handoff_update_config",
|
|
57
57
|
"handoff_dashboard",
|
|
58
|
+
"handoff_import_context",
|
|
59
|
+
"handoff_refer",
|
|
60
|
+
"handoff_list_referrals",
|
|
61
|
+
"handoff_update_referral",
|
|
58
62
|
];
|
|
59
63
|
|
|
60
64
|
let tool_names: Vec<&str> = tools
|
package/tests/storage_tasks.rs
CHANGED
|
@@ -24,6 +24,9 @@ fn make_task(id: &str, title: &str) -> TaskData {
|
|
|
24
24
|
labels: Vec::new(),
|
|
25
25
|
links: Vec::new(),
|
|
26
26
|
done_criteria: Vec::new(),
|
|
27
|
+
schedule: None,
|
|
28
|
+
dependencies: Vec::new(),
|
|
29
|
+
order: None,
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -300,3 +303,37 @@ fn validate_skipped_child_not_terminal_fails() {
|
|
|
300
303
|
let data = make_task("t1", "Parent");
|
|
301
304
|
assert!(validate_skipped_transition(&task_dir, &data).is_err());
|
|
302
305
|
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn is_valid_priority_accepts_valid() {
|
|
309
|
+
assert!(is_valid_priority("low"));
|
|
310
|
+
assert!(is_valid_priority("medium"));
|
|
311
|
+
assert!(is_valid_priority("high"));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#[test]
|
|
315
|
+
fn is_valid_priority_rejects_invalid() {
|
|
316
|
+
assert!(!is_valid_priority("critical"));
|
|
317
|
+
assert!(!is_valid_priority("urgent"));
|
|
318
|
+
assert!(!is_valid_priority(""));
|
|
319
|
+
assert!(!is_valid_priority("HIGH"));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn validate_priority_none_is_ok() {
|
|
324
|
+
assert!(validate_priority(None).is_ok());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#[test]
|
|
328
|
+
fn validate_priority_valid_is_ok() {
|
|
329
|
+
assert!(validate_priority(Some("low")).is_ok());
|
|
330
|
+
assert!(validate_priority(Some("medium")).is_ok());
|
|
331
|
+
assert!(validate_priority(Some("high")).is_ok());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn validate_priority_invalid_is_err() {
|
|
336
|
+
let err = validate_priority(Some("critical")).unwrap_err();
|
|
337
|
+
assert!(err.to_string().contains("Invalid priority"));
|
|
338
|
+
assert!(err.to_string().contains("critical"));
|
|
339
|
+
}
|
|
@@ -375,3 +375,69 @@ fn import_source_recorded_in_environment() {
|
|
|
375
375
|
"markdown"
|
|
376
376
|
);
|
|
377
377
|
}
|
|
378
|
+
|
|
379
|
+
fn is_error(resp: &Value) -> bool {
|
|
380
|
+
resp["result"]["isError"].as_bool().unwrap_or(false)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn import_invalid_priority_rejected() {
|
|
385
|
+
let dir = setup_project();
|
|
386
|
+
let resp = call_tool(
|
|
387
|
+
"handoff_import_context",
|
|
388
|
+
json!({
|
|
389
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
390
|
+
"source": { "description": "bad priority import" },
|
|
391
|
+
"tasks": [
|
|
392
|
+
{ "title": "Task", "priority": "urgent" }
|
|
393
|
+
]
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
assert!(is_error(&resp));
|
|
397
|
+
let text = get_text(&resp);
|
|
398
|
+
assert!(text.contains("Invalid priority"));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn import_valid_priorities_accepted() {
|
|
403
|
+
let dir = setup_project();
|
|
404
|
+
let resp = call_tool(
|
|
405
|
+
"handoff_import_context",
|
|
406
|
+
json!({
|
|
407
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
408
|
+
"source": { "description": "valid priorities" },
|
|
409
|
+
"tasks": [
|
|
410
|
+
{ "title": "Low", "priority": "low" },
|
|
411
|
+
{ "title": "Medium", "priority": "medium" },
|
|
412
|
+
{ "title": "High", "priority": "high" }
|
|
413
|
+
]
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
417
|
+
let text = get_text(&resp);
|
|
418
|
+
assert!(text.contains("Tasks created: 3"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn import_invalid_priority_in_child_rejected() {
|
|
423
|
+
let dir = setup_project();
|
|
424
|
+
let resp = call_tool(
|
|
425
|
+
"handoff_import_context",
|
|
426
|
+
json!({
|
|
427
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
428
|
+
"source": { "description": "bad child priority" },
|
|
429
|
+
"tasks": [
|
|
430
|
+
{
|
|
431
|
+
"title": "Parent",
|
|
432
|
+
"priority": "high",
|
|
433
|
+
"children": [
|
|
434
|
+
{ "title": "Child", "priority": "ASAP" }
|
|
435
|
+
]
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
assert!(is_error(&resp));
|
|
441
|
+
let text = get_text(&resp);
|
|
442
|
+
assert!(text.contains("Invalid priority"));
|
|
443
|
+
}
|