handoff-mcp-server 0.1.0 → 0.3.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 CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.1.0"
147
+ version = "0.3.0"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -55,9 +55,12 @@ Add to your Claude Code MCP configuration:
55
55
 
56
56
  ```json
57
57
  {
58
- "handoff": {
59
- "command": "handoff-mcp",
60
- "args": []
58
+ "mcpServers": {
59
+ "handoff": {
60
+ "type": "stdio",
61
+ "command": "handoff-mcp",
62
+ "args": []
63
+ }
61
64
  }
62
65
  }
63
66
  ```
@@ -66,9 +69,12 @@ Add to your Claude Code MCP configuration:
66
69
 
67
70
  ```json
68
71
  {
69
- "handoff": {
70
- "command": "handoff-mcp",
71
- "args": []
72
+ "mcpServers": {
73
+ "handoff": {
74
+ "type": "stdio",
75
+ "command": "handoff-mcp",
76
+ "args": []
77
+ }
72
78
  }
73
79
  }
74
80
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -0,0 +1,68 @@
1
+ use anyhow::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::{find_task_dir_by_id, find_task_file, read_task, write_task};
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_id = arguments
16
+ .get("task_id")
17
+ .and_then(|v| v.as_str())
18
+ .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
19
+
20
+ let criterion_index = arguments
21
+ .get("criterion_index")
22
+ .and_then(|v| v.as_u64())
23
+ .ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
24
+ as usize;
25
+
26
+ let checked = arguments
27
+ .get("checked")
28
+ .and_then(|v| v.as_bool())
29
+ .ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
30
+
31
+ let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
32
+ .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
33
+
34
+ let (mut data, status) = read_task(&task_dir)?
35
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
36
+
37
+ if criterion_index >= data.done_criteria.len() {
38
+ anyhow::bail!(
39
+ "criterion_index {criterion_index} is out of range (task has {} criteria)",
40
+ data.done_criteria.len()
41
+ );
42
+ }
43
+
44
+ data.done_criteria[criterion_index].checked = checked;
45
+ data.updated_at = Some(Utc::now().to_rfc3339());
46
+
47
+ if let Some((old_path, _)) = find_task_file(&task_dir)? {
48
+ std::fs::remove_file(&old_path)?;
49
+ }
50
+
51
+ write_task(&task_dir, &status, &data)?;
52
+
53
+ let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
54
+ let total = data.done_criteria.len();
55
+
56
+ let result = serde_json::json!({
57
+ "task_id": data.id,
58
+ "criterion_index": criterion_index,
59
+ "item": data.done_criteria[criterion_index].item,
60
+ "checked": checked,
61
+ "done_criteria_summary": {
62
+ "total": total,
63
+ "checked": checked_count,
64
+ }
65
+ });
66
+
67
+ serde_json::to_string_pretty(&result).map_err(Into::into)
68
+ }
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
4
4
  use serde_json::Value;
5
5
 
6
6
  use crate::storage::config::read_config;
7
+ use crate::storage::expand_tilde;
8
+ use crate::storage::referrals::read_referral_summaries;
7
9
  use crate::storage::sessions::read_active_sessions;
8
10
  use crate::storage::tasks::build_task_index;
9
11
 
@@ -88,6 +90,10 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
88
90
  .flat_map(|s| s.blockers.iter().cloned())
89
91
  .collect();
90
92
 
93
+ let unread_referrals = read_referral_summaries(&handoff_dir.join("referrals"), Some("open"))
94
+ .map(|r| r.len() as u32)
95
+ .unwrap_or(0);
96
+
91
97
  Ok(serde_json::json!({
92
98
  "name": config.project.name,
93
99
  "path": project_path.to_string_lossy(),
@@ -96,14 +102,6 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
96
102
  "active_tasks": active_tasks,
97
103
  "blocked_tasks": blocked_tasks,
98
104
  "blockers": blockers,
105
+ "unread_referrals": unread_referrals,
99
106
  }))
100
107
  }
101
-
102
- fn expand_tilde(path: &str) -> String {
103
- if let Some(rest) = path.strip_prefix("~/") {
104
- if let Ok(home) = std::env::var("HOME") {
105
- return format!("{home}/{rest}");
106
- }
107
- }
108
- path.to_string()
109
- }
@@ -0,0 +1,40 @@
1
+ use anyhow::Result;
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::tasks::{find_task_dir_by_id, read_task};
7
+
8
+ pub fn handle(arguments: &Value) -> Result<String> {
9
+ let project_dir = resolve_project_dir(arguments)?;
10
+
11
+ let handoff = ensure_handoff_exists(&project_dir)?;
12
+ let tasks_dir = handoff.join("tasks");
13
+
14
+ let task_id = arguments
15
+ .get("task_id")
16
+ .and_then(|v| v.as_str())
17
+ .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
18
+
19
+ let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
20
+ .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
21
+
22
+ let (data, status) = read_task(&task_dir)?
23
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
24
+
25
+ let result = serde_json::json!({
26
+ "id": data.id,
27
+ "title": data.title,
28
+ "status": status,
29
+ "notes": data.notes,
30
+ "priority": data.priority,
31
+ "created_at": data.created_at,
32
+ "updated_at": data.updated_at,
33
+ "completed_at": data.completed_at,
34
+ "labels": data.labels,
35
+ "links": data.links,
36
+ "done_criteria": data.done_criteria,
37
+ });
38
+
39
+ serde_json::to_string_pretty(&result).map_err(Into::into)
40
+ }
@@ -0,0 +1,310 @@
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::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
+ use crate::storage::tasks::*;
13
+
14
+ pub fn handle(arguments: &Value) -> Result<String> {
15
+ let project_dir = resolve_project_dir(arguments)?;
16
+ let handoff = ensure_handoff_exists(&project_dir)?;
17
+ let tasks_dir = handoff.join("tasks");
18
+ let sessions_dir = handoff.join("sessions");
19
+ let config_path = handoff.join("config.toml");
20
+
21
+ let source = arguments
22
+ .get("source")
23
+ .ok_or_else(|| anyhow::anyhow!("'source' parameter is required"))?;
24
+ let source_description = source
25
+ .get("description")
26
+ .and_then(|v| v.as_str())
27
+ .ok_or_else(|| anyhow::anyhow!("'source.description' is required"))?;
28
+ let source_format = source
29
+ .get("format")
30
+ .and_then(|v| v.as_str())
31
+ .unwrap_or("other");
32
+
33
+ let mut tasks_created: u32 = 0;
34
+ let mut top_level_count: u32 = 0;
35
+ let mut nested_count: u32 = 0;
36
+
37
+ if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
38
+ for task_val in tasks {
39
+ let title = task_val
40
+ .get("title")
41
+ .and_then(|v| v.as_str())
42
+ .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
43
+
44
+ let task_id = next_top_level_id(&tasks_dir)?;
45
+ let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
46
+ tasks_created += count;
47
+ top_level_count += 1;
48
+ nested_count += count - 1;
49
+ }
50
+ }
51
+
52
+ let mut session_saved = false;
53
+ if let Some(session) = arguments.get("session") {
54
+ let summary = session
55
+ .get("summary")
56
+ .and_then(|v| v.as_str())
57
+ .ok_or_else(|| {
58
+ anyhow::anyhow!("'session.summary' is required when session is provided")
59
+ })?;
60
+
61
+ let closed = close_active_sessions(&sessions_dir)?;
62
+ let git_state = capture_git_state(&project_dir)?;
63
+ let now = Utc::now().to_rfc3339();
64
+
65
+ let mut handoff_notes = extract_array(session, "handoff_notes");
66
+
67
+ if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
68
+ if !raw_notes.is_empty() {
69
+ handoff_notes.push(serde_json::json!({
70
+ "note": raw_notes,
71
+ "category": "context"
72
+ }));
73
+ }
74
+ }
75
+
76
+ let mut environment = session
77
+ .get("environment")
78
+ .cloned()
79
+ .unwrap_or(serde_json::json!({}));
80
+ if let Some(env_obj) = environment.as_object_mut() {
81
+ env_obj.insert(
82
+ "import_source".to_string(),
83
+ serde_json::json!({
84
+ "description": source_description,
85
+ "format": source_format
86
+ }),
87
+ );
88
+ }
89
+
90
+ let data = SessionData {
91
+ version: 2,
92
+ ended_at: Some(now),
93
+ summary: summary.to_string(),
94
+ branch: Some(git_state.branch),
95
+ commit: Some(git_state.commit),
96
+ dirty_files: git_state.dirty_files,
97
+ decisions: extract_array(session, "decisions"),
98
+ blockers: extract_string_array(session, "blockers"),
99
+ checklist: extract_array(session, "checklist"),
100
+ handoff_notes,
101
+ references: extract_array(session, "references"),
102
+ context_pointers: extract_array(session, "context_pointers"),
103
+ environment: Some(environment),
104
+ };
105
+
106
+ write_active_session(&sessions_dir, &data)?;
107
+
108
+ let history_limit = if config_path.exists() {
109
+ read_config(&config_path)
110
+ .map(|c| c.settings.history_limit)
111
+ .unwrap_or(20)
112
+ } else {
113
+ 20
114
+ };
115
+ enforce_history_limit(&sessions_dir, history_limit)?;
116
+
117
+ let _ = closed;
118
+ session_saved = true;
119
+ } else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
120
+ if !raw_notes.is_empty() {
121
+ let closed = close_active_sessions(&sessions_dir)?;
122
+ let git_state = capture_git_state(&project_dir)?;
123
+ let now = Utc::now().to_rfc3339();
124
+
125
+ let data = SessionData {
126
+ version: 2,
127
+ ended_at: Some(now),
128
+ summary: format!("[import] {source_description}"),
129
+ branch: Some(git_state.branch),
130
+ commit: Some(git_state.commit),
131
+ dirty_files: git_state.dirty_files,
132
+ decisions: Vec::new(),
133
+ blockers: Vec::new(),
134
+ checklist: Vec::new(),
135
+ handoff_notes: vec![serde_json::json!({
136
+ "note": raw_notes,
137
+ "category": "context"
138
+ })],
139
+ references: Vec::new(),
140
+ context_pointers: Vec::new(),
141
+ environment: Some(serde_json::json!({
142
+ "import_source": {
143
+ "description": source_description,
144
+ "format": source_format
145
+ }
146
+ })),
147
+ };
148
+
149
+ write_active_session(&sessions_dir, &data)?;
150
+
151
+ let history_limit = if config_path.exists() {
152
+ read_config(&config_path)
153
+ .map(|c| c.settings.history_limit)
154
+ .unwrap_or(20)
155
+ } else {
156
+ 20
157
+ };
158
+ enforce_history_limit(&sessions_dir, history_limit)?;
159
+
160
+ let _ = closed;
161
+ session_saved = true;
162
+ }
163
+ }
164
+
165
+ let mut msg = format!("Import complete:\n Source: {source_description}");
166
+
167
+ if tasks_created > 0 {
168
+ msg.push_str(&format!(
169
+ "\n Tasks created: {tasks_created} ({top_level_count} top-level, {nested_count} nested)"
170
+ ));
171
+ } else {
172
+ msg.push_str("\n Tasks created: 0");
173
+ }
174
+
175
+ if session_saved {
176
+ msg.push_str("\n Session saved: yes");
177
+ }
178
+
179
+ if arguments
180
+ .get("raw_notes")
181
+ .and_then(|v| v.as_str())
182
+ .is_some_and(|s| !s.is_empty())
183
+ {
184
+ msg.push_str("\n Raw notes: saved as handoff_note (context)");
185
+ }
186
+
187
+ Ok(msg)
188
+ }
189
+
190
+ fn create_task_recursive(
191
+ tasks_dir: &std::path::Path,
192
+ task_id: &str,
193
+ parent_dir: Option<&std::path::Path>,
194
+ title: &str,
195
+ task_val: &Value,
196
+ ) -> Result<u32> {
197
+ let slug = title_to_slug(title);
198
+ let dir_name = format!("{task_id}-{slug}");
199
+ let base_dir = parent_dir.unwrap_or(tasks_dir);
200
+ let task_dir = base_dir.join(&dir_name);
201
+
202
+ std::fs::create_dir_all(&task_dir)
203
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
204
+
205
+ let now = Utc::now().to_rfc3339();
206
+ let status = task_val
207
+ .get("status")
208
+ .and_then(|v| v.as_str())
209
+ .unwrap_or("todo");
210
+
211
+ if !is_valid_status(status) {
212
+ anyhow::bail!("Invalid status: {status}");
213
+ }
214
+
215
+ let priority = task_val.get("priority").and_then(|v| v.as_str());
216
+ validate_priority(priority)?;
217
+
218
+ let completed_at = if is_terminal_status(status) {
219
+ Some(now.clone())
220
+ } else {
221
+ None
222
+ };
223
+
224
+ let data = TaskData {
225
+ id: task_id.to_string(),
226
+ title: title.to_string(),
227
+ notes: task_val
228
+ .get("notes")
229
+ .and_then(|v| v.as_str())
230
+ .map(String::from),
231
+ priority: priority.map(String::from),
232
+ created_at: Some(now.clone()),
233
+ updated_at: Some(now),
234
+ completed_at,
235
+ labels: extract_string_array_from(task_val, "labels"),
236
+ links: extract_string_array_from(task_val, "links"),
237
+ done_criteria: extract_done_criteria(task_val),
238
+ };
239
+
240
+ write_task(&task_dir, status, &data)?;
241
+
242
+ let mut count: u32 = 1;
243
+
244
+ if let Some(children) = task_val.get("children").and_then(|v| v.as_array()) {
245
+ for (i, child_val) in children.iter().enumerate() {
246
+ let child_title = child_val
247
+ .get("title")
248
+ .and_then(|v| v.as_str())
249
+ .ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
250
+
251
+ let child_id = format!("{task_id}.{}", i + 1);
252
+ count += create_task_recursive(
253
+ &task_dir,
254
+ &child_id,
255
+ Some(&task_dir),
256
+ child_title,
257
+ child_val,
258
+ )?;
259
+ }
260
+ }
261
+
262
+ Ok(count)
263
+ }
264
+
265
+ fn extract_array(val: &Value, key: &str) -> Vec<Value> {
266
+ val.get(key)
267
+ .and_then(|v| v.as_array())
268
+ .cloned()
269
+ .unwrap_or_default()
270
+ }
271
+
272
+ fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
273
+ val.get(key)
274
+ .and_then(|v| v.as_array())
275
+ .map(|arr| {
276
+ arr.iter()
277
+ .filter_map(|v| v.as_str().map(String::from))
278
+ .collect()
279
+ })
280
+ .unwrap_or_default()
281
+ }
282
+
283
+ fn extract_string_array_from(val: &Value, key: &str) -> Vec<String> {
284
+ val.get(key)
285
+ .and_then(|v| v.as_array())
286
+ .map(|arr| {
287
+ arr.iter()
288
+ .filter_map(|v| v.as_str().map(String::from))
289
+ .collect()
290
+ })
291
+ .unwrap_or_default()
292
+ }
293
+
294
+ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
295
+ val.get("done_criteria")
296
+ .and_then(|v| v.as_array())
297
+ .map(|arr| {
298
+ arr.iter()
299
+ .filter_map(|v| {
300
+ let item = v.get("item")?.as_str()?;
301
+ let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
302
+ Some(DoneCriterion {
303
+ item: item.to_string(),
304
+ checked,
305
+ })
306
+ })
307
+ .collect()
308
+ })
309
+ .unwrap_or_default()
310
+ }
@@ -3,6 +3,7 @@ use serde_json::Value;
3
3
 
4
4
  use super::resolve_project_dir;
5
5
  use crate::storage::config::read_config;
6
+ use crate::storage::referrals::read_referral_summaries;
6
7
  use crate::storage::sessions::read_active_sessions;
7
8
  use crate::storage::tasks::build_task_index;
8
9
  use crate::storage::{ensure_handoff_exists, handoff_dir};
@@ -90,5 +91,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
90
91
  result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
91
92
  }
92
93
 
94
+ let referrals_dir = handoff.join("referrals");
95
+ let open_referrals = read_referral_summaries(&referrals_dir, Some("open"))?;
96
+ if !open_referrals.is_empty() {
97
+ result["referrals"] = serde_json::to_value(&open_referrals)?;
98
+ }
99
+
93
100
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
94
101
  }
@@ -1,8 +1,13 @@
1
+ pub mod check_criterion;
1
2
  pub mod config;
2
3
  pub mod dashboard;
4
+ pub mod get_task;
5
+ pub mod import_context;
3
6
  pub mod init;
4
7
  pub mod list_tasks;
5
8
  pub mod load_context;
9
+ pub mod refer;
10
+ pub mod referrals;
6
11
  pub mod save_context;
7
12
  pub mod update_task;
8
13
 
@@ -31,6 +36,12 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
31
36
  "handoff_dashboard" => dashboard::handle(arguments),
32
37
  "handoff_get_config" => config::handle_get(arguments),
33
38
  "handoff_update_config" => config::handle_update(arguments),
39
+ "handoff_get_task" => get_task::handle(arguments),
40
+ "handoff_check_criterion" => check_criterion::handle(arguments),
41
+ "handoff_import_context" => import_context::handle(arguments),
42
+ "handoff_refer" => refer::handle(arguments),
43
+ "handoff_list_referrals" => referrals::handle_list(arguments),
44
+ "handoff_update_referral" => referrals::handle_update(arguments),
34
45
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
35
46
  };
36
47