handoff-mcp-server 0.7.3 → 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 CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.7.3"
147
+ version = "0.8.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.7.3"
3
+ version = "0.8.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.7.3",
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
- .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
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
- .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
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()))?;
@@ -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
- return handle_update(&tasks_dir, existing_id, task_val);
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
- .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
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
- .ok_or_else(|| anyhow::anyhow!("New parent task not found: {new_parent_id}"))?;
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
@@ -244,7 +244,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
244
244
  "task": {
245
245
  "type": "object",
246
246
  "properties": {
247
- "id": { "type": "string", "description": "Task ID. Omit for new task (auto-generated)." },
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)." },
248
248
  "title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
249
249
  "status": {
250
250
  "type": "string",