handoff-mcp-server 0.7.3 → 0.8.1
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 +5 -2
- package/src/mcp/handlers/get_task.rs +5 -2
- package/src/mcp/handlers/import_context.rs +11 -2
- package/src/mcp/handlers/save_context.rs +35 -6
- package/src/mcp/handlers/update_task.rs +93 -5
- package/src/mcp/tools.rs +9 -1
- package/src/storage/sessions.rs +4 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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>",
|
|
@@ -28,8 +28,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
28
28
|
.and_then(|v| v.as_bool())
|
|
29
29
|
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
30
30
|
|
|
31
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
32
|
-
|
|
31
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?.ok_or_else(|| {
|
|
32
|
+
anyhow::anyhow!(
|
|
33
|
+
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
34
|
+
)
|
|
35
|
+
})?;
|
|
33
36
|
|
|
34
37
|
let (mut data, status) = read_task(&task_dir)?
|
|
35
38
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -16,8 +16,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
16
16
|
.and_then(|v| v.as_str())
|
|
17
17
|
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
18
18
|
|
|
19
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
20
|
-
|
|
19
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?.ok_or_else(|| {
|
|
20
|
+
anyhow::anyhow!(
|
|
21
|
+
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
22
|
+
)
|
|
23
|
+
})?;
|
|
21
24
|
|
|
22
25
|
let (data, status) = read_task(&task_dir)?
|
|
23
26
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -49,6 +49,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
let skip_session_close = arguments
|
|
53
|
+
.get("skip_session_close")
|
|
54
|
+
.and_then(|v| v.as_bool())
|
|
55
|
+
.unwrap_or(false);
|
|
56
|
+
|
|
52
57
|
let mut session_saved = false;
|
|
53
58
|
if let Some(session) = arguments.get("session") {
|
|
54
59
|
let summary = session
|
|
@@ -58,7 +63,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
58
63
|
anyhow::anyhow!("'session.summary' is required when session is provided")
|
|
59
64
|
})?;
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
if !skip_session_close {
|
|
67
|
+
close_active_sessions(&sessions_dir)?;
|
|
68
|
+
}
|
|
62
69
|
let git_state = capture_git_state(&project_dir)?;
|
|
63
70
|
let now = Utc::now().to_rfc3339();
|
|
64
71
|
|
|
@@ -118,7 +125,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
118
125
|
session_saved = true;
|
|
119
126
|
} else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
120
127
|
if !raw_notes.is_empty() {
|
|
121
|
-
|
|
128
|
+
if !skip_session_close {
|
|
129
|
+
close_active_sessions(&sessions_dir)?;
|
|
130
|
+
}
|
|
122
131
|
let git_state = capture_git_state(&project_dir)?;
|
|
123
132
|
let now = Utc::now().to_rfc3339();
|
|
124
133
|
|
|
@@ -21,11 +21,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
21
21
|
let sessions_dir = handoff.join("sessions");
|
|
22
22
|
let config_path = handoff.join("config.toml");
|
|
23
23
|
|
|
24
|
-
let summary = arguments
|
|
25
|
-
.get("summary")
|
|
26
|
-
.and_then(|v| v.as_str())
|
|
27
|
-
.ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
|
|
28
|
-
|
|
29
24
|
let close_id = arguments.get("close_session_id").and_then(|v| v.as_str());
|
|
30
25
|
let pause_id = arguments.get("pause_session_id").and_then(|v| v.as_str());
|
|
31
26
|
let pause_all = arguments
|
|
@@ -37,6 +32,19 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
37
32
|
.and_then(|v| v.as_str())
|
|
38
33
|
.unwrap_or("open");
|
|
39
34
|
|
|
35
|
+
let is_pause_only = arguments
|
|
36
|
+
.get("pause_only")
|
|
37
|
+
.and_then(|v| v.as_bool())
|
|
38
|
+
.unwrap_or(false);
|
|
39
|
+
|
|
40
|
+
let summary_opt = arguments.get("summary").and_then(|v| v.as_str());
|
|
41
|
+
|
|
42
|
+
if !is_pause_only && summary_opt.is_none() {
|
|
43
|
+
anyhow::bail!("'summary' is required (unless pause_only=true)");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let summary = summary_opt.unwrap_or("");
|
|
47
|
+
|
|
40
48
|
if session_status != "open" && session_status != "active" {
|
|
41
49
|
anyhow::bail!(
|
|
42
50
|
"Invalid session_status '{}': must be 'open' or 'active'",
|
|
@@ -53,6 +61,27 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
53
61
|
total_paused = pause_active_sessions(&sessions_dir)?.len();
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
if is_pause_only {
|
|
65
|
+
let mut msg = String::new();
|
|
66
|
+
if total_paused > 0 {
|
|
67
|
+
msg.push_str(&format!("Paused {} session(s)", total_paused));
|
|
68
|
+
}
|
|
69
|
+
if let Some(id) = pause_id {
|
|
70
|
+
if total_paused == 0 {
|
|
71
|
+
msg.push_str(&format!(
|
|
72
|
+
"Warning: pause_session_id '{id}' not found among active/open sessions"
|
|
73
|
+
));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if !pause_all && pause_id.is_none() {
|
|
77
|
+
msg.push_str("Warning: pause_only=true requires pause_session_id or pause_active=true");
|
|
78
|
+
}
|
|
79
|
+
if msg.is_empty() {
|
|
80
|
+
msg.push_str("No sessions were paused (none active)");
|
|
81
|
+
}
|
|
82
|
+
return Ok(msg);
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
let total_closed = if let Some(id) = close_id {
|
|
57
86
|
let closed = close_session_by_id(&sessions_dir, id)?;
|
|
58
87
|
if closed.is_some() {
|
|
@@ -124,7 +153,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
124
153
|
if let Some(id) = pause_id {
|
|
125
154
|
if total_paused == 0 {
|
|
126
155
|
msg.push_str(&format!(
|
|
127
|
-
"\nWarning: pause_session_id '{id}' not found among active sessions"
|
|
156
|
+
"\nWarning: pause_session_id '{id}' not found among active/open sessions"
|
|
128
157
|
));
|
|
129
158
|
}
|
|
130
159
|
}
|
|
@@ -23,7 +23,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
23
23
|
if let Some(new_parent_id) = move_to {
|
|
24
24
|
return handle_move(&tasks_dir, existing_id, new_parent_id);
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
|
|
27
|
+
if task_exists {
|
|
28
|
+
return handle_update(&tasks_dir, existing_id, task_val);
|
|
29
|
+
}
|
|
30
|
+
return handle_upsert_create(&tasks_dir, existing_id, task_val, arguments);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
let title = task_val
|
|
@@ -106,6 +110,84 @@ fn handle_create(
|
|
|
106
110
|
Ok(format!("Created task {new_id}: {title} [{status}]"))
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
fn handle_upsert_create(
|
|
114
|
+
tasks_dir: &std::path::Path,
|
|
115
|
+
task_id: &str,
|
|
116
|
+
task_val: &Value,
|
|
117
|
+
arguments: &Value,
|
|
118
|
+
) -> Result<String> {
|
|
119
|
+
let title = task_val
|
|
120
|
+
.get("title")
|
|
121
|
+
.and_then(|v| v.as_str())
|
|
122
|
+
.ok_or_else(|| {
|
|
123
|
+
anyhow::anyhow!(
|
|
124
|
+
"Task '{task_id}' does not exist and cannot be created without a title. \
|
|
125
|
+
Provide 'title' to create a new task with this ID, or use handoff_list_tasks to find existing task IDs."
|
|
126
|
+
)
|
|
127
|
+
})?;
|
|
128
|
+
|
|
129
|
+
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
130
|
+
|
|
131
|
+
let parent_dir = match parent_id {
|
|
132
|
+
Some(pid) => find_task_dir_by_id(tasks_dir, pid)?.ok_or_else(|| {
|
|
133
|
+
anyhow::anyhow!(
|
|
134
|
+
"Parent task not found: {pid}. Use handoff_list_tasks to see available task IDs."
|
|
135
|
+
)
|
|
136
|
+
})?,
|
|
137
|
+
None => tasks_dir.to_path_buf(),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
let slug = title_to_slug(title);
|
|
141
|
+
let dir_name = format!("{task_id}-{slug}");
|
|
142
|
+
let task_dir = parent_dir.join(&dir_name);
|
|
143
|
+
std::fs::create_dir_all(&task_dir)
|
|
144
|
+
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
145
|
+
|
|
146
|
+
let now = Utc::now().to_rfc3339();
|
|
147
|
+
let status = task_val
|
|
148
|
+
.get("status")
|
|
149
|
+
.and_then(|v| v.as_str())
|
|
150
|
+
.unwrap_or("todo");
|
|
151
|
+
|
|
152
|
+
if !is_valid_status(status) {
|
|
153
|
+
anyhow::bail!("Invalid status: {status}");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
157
|
+
validate_priority(priority)?;
|
|
158
|
+
|
|
159
|
+
let dependencies = extract_string_array(task_val, "dependencies");
|
|
160
|
+
if !dependencies.is_empty() {
|
|
161
|
+
validate_dependencies(tasks_dir, task_id, &dependencies)?;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let data = TaskData {
|
|
165
|
+
id: task_id.to_string(),
|
|
166
|
+
title: title.to_string(),
|
|
167
|
+
notes: task_val
|
|
168
|
+
.get("notes")
|
|
169
|
+
.and_then(|v| v.as_str())
|
|
170
|
+
.map(String::from),
|
|
171
|
+
priority: priority.map(String::from),
|
|
172
|
+
created_at: Some(now.clone()),
|
|
173
|
+
updated_at: Some(now),
|
|
174
|
+
completed_at: None,
|
|
175
|
+
labels: extract_string_array(task_val, "labels"),
|
|
176
|
+
links: extract_string_array(task_val, "links"),
|
|
177
|
+
done_criteria: extract_done_criteria(task_val),
|
|
178
|
+
schedule: extract_schedule(task_val),
|
|
179
|
+
dependencies,
|
|
180
|
+
order: task_val
|
|
181
|
+
.get("order")
|
|
182
|
+
.and_then(|v| v.as_u64())
|
|
183
|
+
.map(|v| v as u32),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
write_task(&task_dir, status, &data)?;
|
|
187
|
+
|
|
188
|
+
Ok(format!("Created task {task_id}: {title} [{status}]"))
|
|
189
|
+
}
|
|
190
|
+
|
|
109
191
|
fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -> Result<String> {
|
|
110
192
|
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
111
193
|
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
@@ -179,11 +261,17 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
179
261
|
}
|
|
180
262
|
|
|
181
263
|
fn handle_move(tasks_dir: &std::path::Path, task_id: &str, new_parent_id: &str) -> Result<String> {
|
|
182
|
-
let task_dir = find_task_dir_by_id(tasks_dir, task_id)
|
|
183
|
-
|
|
264
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?.ok_or_else(|| {
|
|
265
|
+
anyhow::anyhow!(
|
|
266
|
+
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
267
|
+
)
|
|
268
|
+
})?;
|
|
184
269
|
|
|
185
|
-
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)
|
|
186
|
-
|
|
270
|
+
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)?.ok_or_else(|| {
|
|
271
|
+
anyhow::anyhow!(
|
|
272
|
+
"New parent task not found: {new_parent_id}. Use handoff_list_tasks to see available task IDs."
|
|
273
|
+
)
|
|
274
|
+
})?;
|
|
187
275
|
|
|
188
276
|
let dir_name = task_dir
|
|
189
277
|
.file_name()
|
package/src/mcp/tools.rs
CHANGED
|
@@ -75,6 +75,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
75
75
|
"type": "boolean",
|
|
76
76
|
"description": "If true, pause all active sessions instead of closing them. Cannot be combined with close_session_id."
|
|
77
77
|
},
|
|
78
|
+
"pause_only": {
|
|
79
|
+
"type": "boolean",
|
|
80
|
+
"description": "If true, only pause sessions (via pause_session_id or pause_active) without creating a new session. Useful for session switching. When true, summary is optional."
|
|
81
|
+
},
|
|
78
82
|
"decisions": {
|
|
79
83
|
"type": "array",
|
|
80
84
|
"description": "Decisions made during this session",
|
|
@@ -244,7 +248,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
244
248
|
"task": {
|
|
245
249
|
"type": "object",
|
|
246
250
|
"properties": {
|
|
247
|
-
"id": { "type": "string", "description": "Task ID. Omit for new task (
|
|
251
|
+
"id": { "type": "string", "description": "Task ID. Omit for auto-generated ID. If provided and task exists, updates it. If provided and task does not exist, creates a new task with that ID (upsert)." },
|
|
248
252
|
"title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
|
|
249
253
|
"status": {
|
|
250
254
|
"type": "string",
|
|
@@ -479,6 +483,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
479
483
|
"raw_notes": {
|
|
480
484
|
"type": "string",
|
|
481
485
|
"description": "Free-form text that couldn't be structured. Saved as a handoff_note with category 'context'."
|
|
486
|
+
},
|
|
487
|
+
"skip_session_close": {
|
|
488
|
+
"type": "boolean",
|
|
489
|
+
"description": "If true, do not close active sessions before creating the import session. Default false."
|
|
482
490
|
}
|
|
483
491
|
},
|
|
484
492
|
"required": ["source"],
|
package/src/storage/sessions.rs
CHANGED
|
@@ -273,7 +273,10 @@ pub fn pause_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
pub fn pause_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
276
|
-
transition_session_by_id(sessions_dir, session_id, "active", "paused")
|
|
276
|
+
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "paused")? {
|
|
277
|
+
return Ok(Some(path));
|
|
278
|
+
}
|
|
279
|
+
transition_session_by_id(sessions_dir, session_id, "open", "paused")
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
pub fn resume_paused_session_by_id(
|