handoff-mcp-server 0.7.2 → 0.8.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 +5 -2
- package/src/mcp/handlers/get_task.rs +5 -2
- package/src/mcp/handlers/import_context.rs +1 -6
- package/src/mcp/handlers/save_context.rs +17 -8
- package/src/mcp/handlers/update_task.rs +93 -5
- package/src/mcp/tools.rs +8 -2
- package/src/storage/sessions.rs +20 -4
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.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>",
|
|
@@ -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()))?;
|
|
@@ -7,8 +7,7 @@ use crate::storage::config::read_config;
|
|
|
7
7
|
use crate::storage::ensure_handoff_exists;
|
|
8
8
|
use crate::storage::git::capture_git_state;
|
|
9
9
|
use crate::storage::sessions::{
|
|
10
|
-
close_active_sessions,
|
|
11
|
-
SessionData,
|
|
10
|
+
close_active_sessions, enforce_history_limit, write_open_session, SessionData,
|
|
12
11
|
};
|
|
13
12
|
use crate::storage::tasks::*;
|
|
14
13
|
|
|
@@ -60,7 +59,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
60
59
|
})?;
|
|
61
60
|
|
|
62
61
|
close_active_sessions(&sessions_dir)?;
|
|
63
|
-
let closed = close_open_sessions(&sessions_dir)?;
|
|
64
62
|
let git_state = capture_git_state(&project_dir)?;
|
|
65
63
|
let now = Utc::now().to_rfc3339();
|
|
66
64
|
|
|
@@ -117,12 +115,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
117
115
|
};
|
|
118
116
|
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
119
117
|
|
|
120
|
-
let _ = closed;
|
|
121
118
|
session_saved = true;
|
|
122
119
|
} else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
123
120
|
if !raw_notes.is_empty() {
|
|
124
121
|
close_active_sessions(&sessions_dir)?;
|
|
125
|
-
let closed = close_open_sessions(&sessions_dir)?;
|
|
126
122
|
let git_state = capture_git_state(&project_dir)?;
|
|
127
123
|
let now = Utc::now().to_rfc3339();
|
|
128
124
|
|
|
@@ -162,7 +158,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
162
158
|
};
|
|
163
159
|
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
164
160
|
|
|
165
|
-
let _ = closed;
|
|
166
161
|
session_saved = true;
|
|
167
162
|
}
|
|
168
163
|
}
|
|
@@ -9,9 +9,9 @@ use crate::storage::config::read_config;
|
|
|
9
9
|
use crate::storage::ensure_handoff_exists;
|
|
10
10
|
use crate::storage::git::capture_git_state;
|
|
11
11
|
use crate::storage::sessions::{
|
|
12
|
-
close_active_sessions,
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
close_active_sessions, close_session_by_id, enforce_history_limit, generate_session_id,
|
|
13
|
+
pause_active_sessions, pause_session_by_id, read_active_sessions, write_session_with_status,
|
|
14
|
+
SessionData,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -32,6 +32,17 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
32
32
|
.get("pause_active")
|
|
33
33
|
.and_then(|v| v.as_bool())
|
|
34
34
|
.unwrap_or(false);
|
|
35
|
+
let session_status = arguments
|
|
36
|
+
.get("session_status")
|
|
37
|
+
.and_then(|v| v.as_str())
|
|
38
|
+
.unwrap_or("open");
|
|
39
|
+
|
|
40
|
+
if session_status != "open" && session_status != "active" {
|
|
41
|
+
anyhow::bail!(
|
|
42
|
+
"Invalid session_status '{}': must be 'open' or 'active'",
|
|
43
|
+
session_status
|
|
44
|
+
);
|
|
45
|
+
}
|
|
35
46
|
|
|
36
47
|
let mut total_paused = 0usize;
|
|
37
48
|
if let Some(id) = pause_id {
|
|
@@ -50,8 +61,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
50
61
|
0
|
|
51
62
|
}
|
|
52
63
|
} else if pause_id.is_some() || pause_all {
|
|
53
|
-
|
|
54
|
-
closed_open.len()
|
|
64
|
+
0
|
|
55
65
|
} else {
|
|
56
66
|
let active = read_active_sessions(&sessions_dir)?;
|
|
57
67
|
if active.len() > 1 {
|
|
@@ -64,8 +74,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
64
74
|
);
|
|
65
75
|
}
|
|
66
76
|
let closed_active = close_active_sessions(&sessions_dir)?;
|
|
67
|
-
|
|
68
|
-
closed_active.len() + closed_open.len()
|
|
77
|
+
closed_active.len()
|
|
69
78
|
};
|
|
70
79
|
|
|
71
80
|
let git_state = capture_git_state(&project_dir)?;
|
|
@@ -89,7 +98,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
89
98
|
environment: arguments.get("environment").cloned(),
|
|
90
99
|
};
|
|
91
100
|
|
|
92
|
-
let path =
|
|
101
|
+
let path = write_session_with_status(&sessions_dir, &data, session_status)?;
|
|
93
102
|
|
|
94
103
|
let history_limit = if config_path.exists() {
|
|
95
104
|
read_config(&config_path)
|
|
@@ -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
|
@@ -57,9 +57,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
57
57
|
"type": "string",
|
|
58
58
|
"description": "One-line summary of this session"
|
|
59
59
|
},
|
|
60
|
+
"session_status": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Status of the new session: 'open' (default) = saved but not activated, allows multiple open sessions; 'active' = immediately active for this session.",
|
|
63
|
+
"enum": ["open", "active"],
|
|
64
|
+
"default": "open"
|
|
65
|
+
},
|
|
60
66
|
"close_session_id": {
|
|
61
67
|
"type": "string",
|
|
62
|
-
"description": "Session ID to close. If omitted (and no pause options set),
|
|
68
|
+
"description": "Session ID to close. If omitted (and no pause options set), active sessions are closed."
|
|
63
69
|
},
|
|
64
70
|
"pause_session_id": {
|
|
65
71
|
"type": "string",
|
|
@@ -238,7 +244,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
238
244
|
"task": {
|
|
239
245
|
"type": "object",
|
|
240
246
|
"properties": {
|
|
241
|
-
"id": { "type": "string", "description": "Task ID. Omit for new task (
|
|
247
|
+
"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)." },
|
|
242
248
|
"title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
|
|
243
249
|
"status": {
|
|
244
250
|
"type": "string",
|
package/src/storage/sessions.rs
CHANGED
|
@@ -321,8 +321,24 @@ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
|
321
321
|
Ok(removed)
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
pub fn write_session_with_status(
|
|
325
|
+
sessions_dir: &Path,
|
|
326
|
+
data: &SessionData,
|
|
327
|
+
status: &str,
|
|
328
|
+
) -> Result<PathBuf> {
|
|
329
|
+
let mut data = data.clone();
|
|
330
|
+
if data.id.is_none() {
|
|
331
|
+
data.id = Some(generate_session_id());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let ts_part = compact_timestamp(&data);
|
|
335
|
+
let base = generate_session_filename(&data.summary, &ts_part);
|
|
336
|
+
let filename = format!("{base}.{status}.json");
|
|
337
|
+
let path = sessions_dir.join(&filename);
|
|
338
|
+
|
|
339
|
+
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
340
|
+
std::fs::write(&path, content)
|
|
341
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
342
|
+
|
|
343
|
+
Ok(path)
|
|
328
344
|
}
|