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.
@@ -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(&current_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,6 @@
1
+ pub mod handlers;
2
+ pub mod protocol;
3
+ pub mod resources;
4
+ pub mod router;
5
+ pub mod tools;
6
+ pub mod types;
@@ -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
+ }