handoff-mcp-server 0.1.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 +781 -0
- package/Cargo.toml +24 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/handoff-mcp.js +22 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +44 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +29 -0
- package/src/mcp/handlers/config.rs +115 -0
- package/src/mcp/handlers/dashboard.rs +109 -0
- package/src/mcp/handlers/init.rs +28 -0
- package/src/mcp/handlers/list_tasks.rs +61 -0
- package/src/mcp/handlers/load_context.rs +94 -0
- package/src/mcp/handlers/mod.rs +58 -0
- package/src/mcp/handlers/save_context.rs +96 -0
- package/src/mcp/handlers/update_task.rs +207 -0
- package/src/mcp/mod.rs +6 -0
- package/src/mcp/protocol.rs +41 -0
- package/src/mcp/resources.rs +55 -0
- package/src/mcp/router.rs +150 -0
- package/src/mcp/tools.rs +284 -0
- package/src/mcp/types.rs +108 -0
- package/src/storage/config.rs +112 -0
- package/src/storage/git.rs +47 -0
- package/src/storage/mod.rs +41 -0
- package/src/storage/sessions.rs +167 -0
- package/src/storage/tasks.rs +365 -0
- package/tests/mcp_protocol.rs +204 -0
- package/tests/storage_config.rs +107 -0
- package/tests/storage_sessions.rs +195 -0
- package/tests/storage_tasks.rs +302 -0
- package/tests/tool_dashboard.rs +165 -0
- package/tests/tool_init.rs +176 -0
- package/tests/tool_sessions.rs +318 -0
- package/tests/tool_tasks.rs +408 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::config::read_config;
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
use crate::storage::tasks::build_task_index;
|
|
8
|
+
|
|
9
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
11
|
+
|
|
12
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
13
|
+
let tasks_dir = handoff.join("tasks");
|
|
14
|
+
let config_path = handoff.join("config.toml");
|
|
15
|
+
|
|
16
|
+
let done_task_limit = if config_path.exists() {
|
|
17
|
+
read_config(&config_path)
|
|
18
|
+
.map(|c| c.settings.done_task_limit)
|
|
19
|
+
.unwrap_or(10)
|
|
20
|
+
} else {
|
|
21
|
+
10
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let (tree, summary) = build_task_index(&tasks_dir, done_task_limit)?;
|
|
25
|
+
|
|
26
|
+
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
27
|
+
|
|
28
|
+
let filtered_tree = if let Some(filter) = status_filter {
|
|
29
|
+
filter_tree(&tree, filter)
|
|
30
|
+
} else {
|
|
31
|
+
tree
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let result = serde_json::json!({
|
|
35
|
+
"task_tree": filtered_tree,
|
|
36
|
+
"task_summary": summary,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
serde_json::to_string_pretty(&result).context("Failed to serialize task list")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn filter_tree(
|
|
43
|
+
tree: &[crate::storage::tasks::TaskIndex],
|
|
44
|
+
status: &str,
|
|
45
|
+
) -> Vec<crate::storage::tasks::TaskIndex> {
|
|
46
|
+
tree.iter()
|
|
47
|
+
.filter_map(|node| {
|
|
48
|
+
let children = filter_tree(&node.children, status);
|
|
49
|
+
if node.status == status || !children.is_empty() {
|
|
50
|
+
Some(crate::storage::tasks::TaskIndex {
|
|
51
|
+
id: node.id.clone(),
|
|
52
|
+
title: node.title.clone(),
|
|
53
|
+
status: node.status.clone(),
|
|
54
|
+
children,
|
|
55
|
+
})
|
|
56
|
+
} else {
|
|
57
|
+
None
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.collect()
|
|
61
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::config::read_config;
|
|
6
|
+
use crate::storage::sessions::read_active_sessions;
|
|
7
|
+
use crate::storage::tasks::build_task_index;
|
|
8
|
+
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
9
|
+
|
|
10
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
11
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
12
|
+
|
|
13
|
+
let hdir = handoff_dir(&project_dir);
|
|
14
|
+
if !hdir.exists() {
|
|
15
|
+
let result = serde_json::json!({
|
|
16
|
+
"status": "not_initialized",
|
|
17
|
+
"message": format!(
|
|
18
|
+
"No .handoff/ directory found in {}. Run handoff_init to set up handoff tracking.",
|
|
19
|
+
project_dir.display()
|
|
20
|
+
)
|
|
21
|
+
});
|
|
22
|
+
return serde_json::to_string_pretty(&result).context("serialize");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
26
|
+
let sessions_dir = handoff.join("sessions");
|
|
27
|
+
let tasks_dir = handoff.join("tasks");
|
|
28
|
+
let config_path = handoff.join("config.toml");
|
|
29
|
+
|
|
30
|
+
let config = if config_path.exists() {
|
|
31
|
+
read_config(&config_path)?
|
|
32
|
+
} else {
|
|
33
|
+
anyhow::bail!("config.toml not found");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let sessions = read_active_sessions(&sessions_dir)?;
|
|
37
|
+
|
|
38
|
+
let (task_tree, task_summary) = build_task_index(&tasks_dir, config.settings.done_task_limit)?;
|
|
39
|
+
|
|
40
|
+
let last_session = sessions.last().map(|s| {
|
|
41
|
+
serde_json::json!({
|
|
42
|
+
"ended_at": s.ended_at,
|
|
43
|
+
"summary": s.summary,
|
|
44
|
+
"branch": s.branch,
|
|
45
|
+
"commit": s.commit,
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let active_sessions: Vec<Value> = sessions
|
|
50
|
+
.iter()
|
|
51
|
+
.map(|s| serde_json::to_value(s).unwrap_or_default())
|
|
52
|
+
.collect();
|
|
53
|
+
|
|
54
|
+
let mut result = serde_json::json!({
|
|
55
|
+
"project": config.project.name,
|
|
56
|
+
"task_tree": task_tree,
|
|
57
|
+
"task_summary": task_summary,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if let Some(ls) = last_session {
|
|
61
|
+
result["last_session"] = ls;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if !active_sessions.is_empty() {
|
|
65
|
+
let latest = &active_sessions[active_sessions.len() - 1];
|
|
66
|
+
|
|
67
|
+
for key in [
|
|
68
|
+
"decisions",
|
|
69
|
+
"blockers",
|
|
70
|
+
"checklist",
|
|
71
|
+
"handoff_notes",
|
|
72
|
+
"references",
|
|
73
|
+
"context_pointers",
|
|
74
|
+
] {
|
|
75
|
+
if let Some(val) = latest.get(key) {
|
|
76
|
+
if val.as_array().is_some_and(|a| !a.is_empty()) {
|
|
77
|
+
result[key] = val.clone();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if let Some(env) = latest.get("environment") {
|
|
83
|
+
if !env.is_null() {
|
|
84
|
+
result["environment"] = env.clone();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if !config.settings.context_files.is_empty() {
|
|
90
|
+
result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
serde_json::to_string_pretty(&result).context("Failed to serialize context")
|
|
94
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
pub mod config;
|
|
2
|
+
pub mod dashboard;
|
|
3
|
+
pub mod init;
|
|
4
|
+
pub mod list_tasks;
|
|
5
|
+
pub mod load_context;
|
|
6
|
+
pub mod save_context;
|
|
7
|
+
pub mod update_task;
|
|
8
|
+
|
|
9
|
+
use std::path::PathBuf;
|
|
10
|
+
|
|
11
|
+
use anyhow::{Context, Result};
|
|
12
|
+
use serde_json::Value;
|
|
13
|
+
|
|
14
|
+
use crate::mcp::types::JsonRpcResponse;
|
|
15
|
+
|
|
16
|
+
pub fn resolve_project_dir(arguments: &Value) -> Result<PathBuf> {
|
|
17
|
+
let raw = match arguments.get("project_dir").and_then(|v| v.as_str()) {
|
|
18
|
+
Some(dir) => PathBuf::from(dir),
|
|
19
|
+
None => std::env::current_dir().context("Failed to get current directory")?,
|
|
20
|
+
};
|
|
21
|
+
std::fs::canonicalize(&raw).with_context(|| format!("Invalid project path: {}", raw.display()))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
25
|
+
let result = match name {
|
|
26
|
+
"handoff_init" => init::handle(arguments),
|
|
27
|
+
"handoff_update_task" => update_task::handle(arguments),
|
|
28
|
+
"handoff_list_tasks" => list_tasks::handle(arguments),
|
|
29
|
+
"handoff_save_context" => save_context::handle(arguments),
|
|
30
|
+
"handoff_load_context" => load_context::handle(arguments),
|
|
31
|
+
"handoff_dashboard" => dashboard::handle(arguments),
|
|
32
|
+
"handoff_get_config" => config::handle_get(arguments),
|
|
33
|
+
"handoff_update_config" => config::handle_update(arguments),
|
|
34
|
+
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
match result {
|
|
38
|
+
Ok(content) => {
|
|
39
|
+
let tool_result = serde_json::json!({
|
|
40
|
+
"content": [{
|
|
41
|
+
"type": "text",
|
|
42
|
+
"text": content
|
|
43
|
+
}]
|
|
44
|
+
});
|
|
45
|
+
JsonRpcResponse::success(None, tool_result)
|
|
46
|
+
}
|
|
47
|
+
Err(e) => {
|
|
48
|
+
let tool_result = serde_json::json!({
|
|
49
|
+
"isError": true,
|
|
50
|
+
"content": [{
|
|
51
|
+
"type": "text",
|
|
52
|
+
"text": format!("Error: {e}")
|
|
53
|
+
}]
|
|
54
|
+
});
|
|
55
|
+
JsonRpcResponse::success(None, tool_result)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::ensure_handoff_exists;
|
|
8
|
+
use crate::storage::git::capture_git_state;
|
|
9
|
+
use crate::storage::sessions::{
|
|
10
|
+
close_active_sessions, enforce_history_limit, write_active_session, SessionData,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
14
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
15
|
+
|
|
16
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
17
|
+
let sessions_dir = handoff.join("sessions");
|
|
18
|
+
let config_path = handoff.join("config.toml");
|
|
19
|
+
|
|
20
|
+
let summary = arguments
|
|
21
|
+
.get("summary")
|
|
22
|
+
.and_then(|v| v.as_str())
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
|
|
24
|
+
|
|
25
|
+
let closed = close_active_sessions(&sessions_dir)?;
|
|
26
|
+
|
|
27
|
+
let git_state = capture_git_state(&project_dir)?;
|
|
28
|
+
let now = Utc::now().to_rfc3339();
|
|
29
|
+
|
|
30
|
+
let data = SessionData {
|
|
31
|
+
version: 2,
|
|
32
|
+
ended_at: Some(now),
|
|
33
|
+
summary: summary.to_string(),
|
|
34
|
+
branch: Some(git_state.branch),
|
|
35
|
+
commit: Some(git_state.commit),
|
|
36
|
+
dirty_files: git_state.dirty_files,
|
|
37
|
+
decisions: extract_array(arguments, "decisions"),
|
|
38
|
+
blockers: extract_string_array(arguments, "blockers"),
|
|
39
|
+
checklist: extract_array(arguments, "checklist"),
|
|
40
|
+
handoff_notes: extract_array(arguments, "handoff_notes"),
|
|
41
|
+
references: extract_array(arguments, "references"),
|
|
42
|
+
context_pointers: extract_array(arguments, "context_pointers"),
|
|
43
|
+
environment: arguments.get("environment").cloned(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let path = write_active_session(&sessions_dir, &data)?;
|
|
47
|
+
|
|
48
|
+
let history_limit = if config_path.exists() {
|
|
49
|
+
read_config(&config_path)
|
|
50
|
+
.map(|c| c.settings.history_limit)
|
|
51
|
+
.unwrap_or(20)
|
|
52
|
+
} else {
|
|
53
|
+
20
|
|
54
|
+
};
|
|
55
|
+
let removed = enforce_history_limit(&sessions_dir, history_limit)?;
|
|
56
|
+
|
|
57
|
+
let mut msg = format!(
|
|
58
|
+
"Session saved: {}\nFile: {}",
|
|
59
|
+
summary,
|
|
60
|
+
path.file_name()
|
|
61
|
+
.map(|n| n.to_string_lossy().to_string())
|
|
62
|
+
.unwrap_or_default()
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if !closed.is_empty() {
|
|
66
|
+
msg.push_str(&format!(
|
|
67
|
+
"\nClosed {} previous active session(s)",
|
|
68
|
+
closed.len()
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
if removed > 0 {
|
|
72
|
+
msg.push_str(&format!(
|
|
73
|
+
"\nRemoved {removed} old session(s) (history_limit: {history_limit})"
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Ok(msg)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn extract_array(val: &Value, key: &str) -> Vec<Value> {
|
|
81
|
+
val.get(key)
|
|
82
|
+
.and_then(|v| v.as_array())
|
|
83
|
+
.cloned()
|
|
84
|
+
.unwrap_or_default()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
|
|
88
|
+
val.get(key)
|
|
89
|
+
.and_then(|v| v.as_array())
|
|
90
|
+
.map(|arr| {
|
|
91
|
+
arr.iter()
|
|
92
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
93
|
+
.collect()
|
|
94
|
+
})
|
|
95
|
+
.unwrap_or_default()
|
|
96
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
use crate::storage::tasks::*;
|
|
8
|
+
|
|
9
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
11
|
+
|
|
12
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
13
|
+
let tasks_dir = handoff.join("tasks");
|
|
14
|
+
|
|
15
|
+
let task_val = arguments
|
|
16
|
+
.get("task")
|
|
17
|
+
.ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
|
|
18
|
+
|
|
19
|
+
let title = task_val
|
|
20
|
+
.get("title")
|
|
21
|
+
.and_then(|v| v.as_str())
|
|
22
|
+
.ok_or_else(|| anyhow::anyhow!("'task.title' is required"))?;
|
|
23
|
+
|
|
24
|
+
let task_id = task_val.get("id").and_then(|v| v.as_str());
|
|
25
|
+
let move_to = arguments.get("move_to").and_then(|v| v.as_str());
|
|
26
|
+
|
|
27
|
+
if let Some(existing_id) = task_id {
|
|
28
|
+
if let Some(new_parent_id) = move_to {
|
|
29
|
+
return handle_move(&tasks_dir, existing_id, new_parent_id);
|
|
30
|
+
}
|
|
31
|
+
return handle_update(&tasks_dir, existing_id, task_val);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
handle_create(&tasks_dir, title, task_val, arguments)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn handle_create(
|
|
38
|
+
tasks_dir: &std::path::Path,
|
|
39
|
+
title: &str,
|
|
40
|
+
task_val: &Value,
|
|
41
|
+
arguments: &Value,
|
|
42
|
+
) -> Result<String> {
|
|
43
|
+
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
44
|
+
|
|
45
|
+
let (new_id, parent_dir) = match parent_id {
|
|
46
|
+
Some(pid) => {
|
|
47
|
+
let parent_dir = find_task_dir_by_id(tasks_dir, pid)?
|
|
48
|
+
.ok_or_else(|| anyhow::anyhow!("Parent task not found: {pid}"))?;
|
|
49
|
+
let id = next_child_id(&parent_dir, pid)?;
|
|
50
|
+
(id, parent_dir)
|
|
51
|
+
}
|
|
52
|
+
None => {
|
|
53
|
+
let id = next_top_level_id(tasks_dir)?;
|
|
54
|
+
(id, tasks_dir.to_path_buf())
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let slug = title_to_slug(title);
|
|
59
|
+
let dir_name = format!("{new_id}-{slug}");
|
|
60
|
+
let task_dir = parent_dir.join(&dir_name);
|
|
61
|
+
std::fs::create_dir_all(&task_dir)
|
|
62
|
+
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
63
|
+
|
|
64
|
+
let now = Utc::now().to_rfc3339();
|
|
65
|
+
let status = task_val
|
|
66
|
+
.get("status")
|
|
67
|
+
.and_then(|v| v.as_str())
|
|
68
|
+
.unwrap_or("todo");
|
|
69
|
+
|
|
70
|
+
if !is_valid_status(status) {
|
|
71
|
+
anyhow::bail!("Invalid status: {status}");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let data = TaskData {
|
|
75
|
+
id: new_id.clone(),
|
|
76
|
+
title: title.to_string(),
|
|
77
|
+
notes: task_val
|
|
78
|
+
.get("notes")
|
|
79
|
+
.and_then(|v| v.as_str())
|
|
80
|
+
.map(String::from),
|
|
81
|
+
priority: task_val
|
|
82
|
+
.get("priority")
|
|
83
|
+
.and_then(|v| v.as_str())
|
|
84
|
+
.map(String::from),
|
|
85
|
+
created_at: Some(now.clone()),
|
|
86
|
+
updated_at: Some(now),
|
|
87
|
+
completed_at: None,
|
|
88
|
+
labels: extract_string_array(task_val, "labels"),
|
|
89
|
+
links: extract_string_array(task_val, "links"),
|
|
90
|
+
done_criteria: extract_done_criteria(task_val),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
write_task(&task_dir, status, &data)?;
|
|
94
|
+
|
|
95
|
+
Ok(format!("Created task {new_id}: {title} [{status}]"))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -> Result<String> {
|
|
99
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
100
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
101
|
+
|
|
102
|
+
let (mut data, current_status) = read_task(&task_dir)?
|
|
103
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
104
|
+
|
|
105
|
+
if let Some(title) = task_val.get("title").and_then(|v| v.as_str()) {
|
|
106
|
+
data.title = title.to_string();
|
|
107
|
+
}
|
|
108
|
+
if let Some(notes) = task_val.get("notes").and_then(|v| v.as_str()) {
|
|
109
|
+
data.notes = Some(notes.to_string());
|
|
110
|
+
}
|
|
111
|
+
if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
|
|
112
|
+
data.priority = Some(priority.to_string());
|
|
113
|
+
}
|
|
114
|
+
if task_val.get("labels").is_some() {
|
|
115
|
+
data.labels = extract_string_array(task_val, "labels");
|
|
116
|
+
}
|
|
117
|
+
if task_val.get("links").is_some() {
|
|
118
|
+
data.links = extract_string_array(task_val, "links");
|
|
119
|
+
}
|
|
120
|
+
if task_val.get("done_criteria").is_some() {
|
|
121
|
+
data.done_criteria = extract_done_criteria(task_val);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let new_status = task_val
|
|
125
|
+
.get("status")
|
|
126
|
+
.and_then(|v| v.as_str())
|
|
127
|
+
.unwrap_or(¤t_status);
|
|
128
|
+
|
|
129
|
+
if !is_valid_status(new_status) {
|
|
130
|
+
anyhow::bail!("Invalid status: {new_status}");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if new_status == "done" && current_status != "done" {
|
|
134
|
+
validate_done_transition(&task_dir, &data)?;
|
|
135
|
+
data.completed_at = Some(Utc::now().to_rfc3339());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if new_status == "skipped" && current_status != "skipped" {
|
|
139
|
+
validate_skipped_transition(&task_dir, &data)?;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
143
|
+
|
|
144
|
+
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
145
|
+
std::fs::remove_file(&old_path)?;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
write_task(&task_dir, new_status, &data)?;
|
|
149
|
+
|
|
150
|
+
Ok(format!(
|
|
151
|
+
"Updated task {task_id}: {} [{new_status}]",
|
|
152
|
+
data.title
|
|
153
|
+
))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn handle_move(tasks_dir: &std::path::Path, task_id: &str, new_parent_id: &str) -> Result<String> {
|
|
157
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
158
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
159
|
+
|
|
160
|
+
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)?
|
|
161
|
+
.ok_or_else(|| anyhow::anyhow!("New parent task not found: {new_parent_id}"))?;
|
|
162
|
+
|
|
163
|
+
let dir_name = task_dir
|
|
164
|
+
.file_name()
|
|
165
|
+
.ok_or_else(|| anyhow::anyhow!("Invalid task dir"))?;
|
|
166
|
+
|
|
167
|
+
let dest = new_parent_dir.join(dir_name);
|
|
168
|
+
|
|
169
|
+
std::fs::rename(&task_dir, &dest).with_context(|| {
|
|
170
|
+
format!(
|
|
171
|
+
"Failed to move {} -> {}",
|
|
172
|
+
task_dir.display(),
|
|
173
|
+
dest.display()
|
|
174
|
+
)
|
|
175
|
+
})?;
|
|
176
|
+
|
|
177
|
+
Ok(format!("Moved task {task_id} under {new_parent_id}"))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
|
|
181
|
+
val.get(key)
|
|
182
|
+
.and_then(|v| v.as_array())
|
|
183
|
+
.map(|arr| {
|
|
184
|
+
arr.iter()
|
|
185
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
186
|
+
.collect()
|
|
187
|
+
})
|
|
188
|
+
.unwrap_or_default()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
192
|
+
val.get("done_criteria")
|
|
193
|
+
.and_then(|v| v.as_array())
|
|
194
|
+
.map(|arr| {
|
|
195
|
+
arr.iter()
|
|
196
|
+
.filter_map(|v| {
|
|
197
|
+
let item = v.get("item")?.as_str()?;
|
|
198
|
+
let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
|
|
199
|
+
Some(DoneCriterion {
|
|
200
|
+
item: item.to_string(),
|
|
201
|
+
checked,
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
.collect()
|
|
205
|
+
})
|
|
206
|
+
.unwrap_or_default()
|
|
207
|
+
}
|
package/src/mcp/mod.rs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
use super::router::handle_request;
|
|
2
|
+
use super::types::{JsonRpcRequest, JsonRpcResponse, INVALID_REQUEST, PARSE_ERROR};
|
|
3
|
+
|
|
4
|
+
pub fn process_line(line: &str) -> Option<String> {
|
|
5
|
+
let trimmed = line.trim();
|
|
6
|
+
if trimmed.is_empty() {
|
|
7
|
+
return None;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let response = match serde_json::from_str::<JsonRpcRequest>(trimmed) {
|
|
11
|
+
Ok(req) => {
|
|
12
|
+
if req.jsonrpc != "2.0" {
|
|
13
|
+
JsonRpcResponse::error(req.id, INVALID_REQUEST, "Invalid JSON-RPC version")
|
|
14
|
+
} else {
|
|
15
|
+
let mut resp = handle_request(&req.method, req.params.as_ref());
|
|
16
|
+
if resp.id.is_none() && req.id.is_some() {
|
|
17
|
+
resp.id = req.id;
|
|
18
|
+
}
|
|
19
|
+
if is_notification(&req.method) && resp.result.is_none() && resp.error.is_none() {
|
|
20
|
+
return None;
|
|
21
|
+
}
|
|
22
|
+
resp
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
Err(e) => JsonRpcResponse::error(None, PARSE_ERROR, format!("Parse error: {e}")),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
match serde_json::to_string(&response) {
|
|
29
|
+
Ok(s) => Some(s),
|
|
30
|
+
Err(e) => {
|
|
31
|
+
let fallback = format!(
|
|
32
|
+
r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"Serialization error: {e}"}}}}"#
|
|
33
|
+
);
|
|
34
|
+
Some(fallback)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn is_notification(method: &str) -> bool {
|
|
40
|
+
method.starts_with("notifications/")
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::{json, Value};
|
|
3
|
+
|
|
4
|
+
use crate::storage::config::read_config;
|
|
5
|
+
use crate::storage::sessions::read_active_sessions;
|
|
6
|
+
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
7
|
+
|
|
8
|
+
pub fn handle_resource_read(uri: &str) -> Result<Value> {
|
|
9
|
+
let project_dir = std::env::current_dir().context("Failed to get current directory")?;
|
|
10
|
+
let hdir = handoff_dir(&project_dir);
|
|
11
|
+
|
|
12
|
+
if !hdir.exists() {
|
|
13
|
+
anyhow::bail!("No .handoff/ directory found. Run handoff_init first.");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
17
|
+
|
|
18
|
+
match uri {
|
|
19
|
+
"handoff://sessions" => read_sessions_resource(&handoff),
|
|
20
|
+
"handoff://config" => read_config_resource(&handoff),
|
|
21
|
+
_ => anyhow::bail!("Unknown resource URI: {uri}"),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn read_sessions_resource(handoff: &std::path::Path) -> Result<Value> {
|
|
26
|
+
let sessions_dir = handoff.join("sessions");
|
|
27
|
+
let sessions = read_active_sessions(&sessions_dir)?;
|
|
28
|
+
|
|
29
|
+
let contents: Vec<Value> = sessions
|
|
30
|
+
.iter()
|
|
31
|
+
.map(|s| serde_json::to_value(s).unwrap_or_default())
|
|
32
|
+
.collect();
|
|
33
|
+
|
|
34
|
+
Ok(json!({
|
|
35
|
+
"contents": [{
|
|
36
|
+
"uri": "handoff://sessions",
|
|
37
|
+
"mimeType": "application/json",
|
|
38
|
+
"text": serde_json::to_string_pretty(&contents)?
|
|
39
|
+
}]
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn read_config_resource(handoff: &std::path::Path) -> Result<Value> {
|
|
44
|
+
let config_path = handoff.join("config.toml");
|
|
45
|
+
let config = read_config(&config_path)?;
|
|
46
|
+
let toml_str = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
|
47
|
+
|
|
48
|
+
Ok(json!({
|
|
49
|
+
"contents": [{
|
|
50
|
+
"uri": "handoff://config",
|
|
51
|
+
"mimeType": "application/toml",
|
|
52
|
+
"text": toml_str
|
|
53
|
+
}]
|
|
54
|
+
}))
|
|
55
|
+
}
|