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.
@@ -0,0 +1,152 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use anyhow::{Context, Result};
4
+ use chrono::Utc;
5
+ use serde_json::Value;
6
+
7
+ use super::resolve_project_dir;
8
+ use crate::storage::config::read_config;
9
+ use crate::storage::expand_tilde;
10
+ use crate::storage::referrals::{is_valid_referral_type, write_referral, ReferralData};
11
+ use crate::storage::tasks::validate_priority;
12
+
13
+ pub fn handle(arguments: &Value) -> Result<String> {
14
+ let source_project_dir = resolve_project_dir(arguments)?;
15
+
16
+ let source_handoff = source_project_dir.join(".handoff");
17
+ if !source_handoff.join("config.toml").exists() {
18
+ anyhow::bail!(
19
+ "Source project is not initialized: {}",
20
+ source_project_dir.display()
21
+ );
22
+ }
23
+
24
+ let source_config = read_config(&source_handoff.join("config.toml"))?;
25
+
26
+ let target_dir = resolve_target(arguments, &source_config.dashboard.scan_dirs)?;
27
+
28
+ let target_handoff = target_dir.join(".handoff");
29
+ if !target_handoff.exists() {
30
+ anyhow::bail!(
31
+ "Target project is not initialized (no .handoff/): {}",
32
+ target_dir.display()
33
+ );
34
+ }
35
+
36
+ let summary = arguments
37
+ .get("summary")
38
+ .and_then(|v| v.as_str())
39
+ .ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
40
+
41
+ let referral_type = arguments
42
+ .get("referral_type")
43
+ .and_then(|v| v.as_str())
44
+ .unwrap_or("request");
45
+
46
+ if !is_valid_referral_type(referral_type) {
47
+ anyhow::bail!(
48
+ "Invalid referral_type: '{referral_type}'. Must be one of: improvement, bug, request, info"
49
+ );
50
+ }
51
+
52
+ let priority = arguments.get("priority").and_then(|v| v.as_str());
53
+ validate_priority(priority)?;
54
+
55
+ let details = arguments
56
+ .get("details")
57
+ .and_then(|v| v.as_str())
58
+ .map(String::from);
59
+
60
+ let tasks: Vec<Value> = arguments
61
+ .get("tasks")
62
+ .and_then(|v| v.as_array())
63
+ .cloned()
64
+ .unwrap_or_default();
65
+
66
+ let context = arguments.get("context").cloned();
67
+
68
+ let now_dt = Utc::now();
69
+ let now = now_dt.to_rfc3339();
70
+ let id = format!("ref-{}", now_dt.format("%Y%m%d-%H%M%S-%f"));
71
+
72
+ let data = ReferralData {
73
+ id: id.clone(),
74
+ source_project: source_config.project.name.clone(),
75
+ source_project_dir: source_project_dir.to_string_lossy().to_string(),
76
+ created_at: now,
77
+ referral_type: referral_type.to_string(),
78
+ summary: summary.to_string(),
79
+ details,
80
+ priority: priority.map(String::from),
81
+ tasks,
82
+ context,
83
+ };
84
+
85
+ let referrals_dir = target_handoff.join("referrals");
86
+ write_referral(&referrals_dir, &data)?;
87
+
88
+ let target_name = if target_handoff.join("config.toml").exists() {
89
+ read_config(&target_handoff.join("config.toml"))
90
+ .map(|c| c.project.name)
91
+ .unwrap_or_else(|_| target_dir.to_string_lossy().to_string())
92
+ } else {
93
+ target_dir.to_string_lossy().to_string()
94
+ };
95
+
96
+ Ok(format!(
97
+ "Referral sent: {id}\n From: {}\n To: {target_name}\n Type: {referral_type}\n Summary: {summary}",
98
+ source_config.project.name
99
+ ))
100
+ }
101
+
102
+ fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
103
+ if let Some(dir) = arguments.get("target_project_dir").and_then(|v| v.as_str()) {
104
+ let path = PathBuf::from(dir);
105
+ return std::fs::canonicalize(&path)
106
+ .with_context(|| format!("Invalid target project path: {}", path.display()));
107
+ }
108
+
109
+ if let Some(name) = arguments.get("target_project").and_then(|v| v.as_str()) {
110
+ return resolve_by_name(name, scan_dirs);
111
+ }
112
+
113
+ anyhow::bail!("Either 'target_project' or 'target_project_dir' is required")
114
+ }
115
+
116
+ fn resolve_by_name(name: &str, scan_dirs: &[String]) -> Result<PathBuf> {
117
+ for scan_dir in scan_dirs {
118
+ let expanded = expand_tilde(scan_dir);
119
+ let expanded_path = Path::new(&expanded);
120
+
121
+ if !expanded_path.exists() {
122
+ continue;
123
+ }
124
+
125
+ let entries = match std::fs::read_dir(expanded_path) {
126
+ Ok(e) => e,
127
+ Err(_) => continue,
128
+ };
129
+
130
+ for entry in entries.flatten() {
131
+ if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
132
+ continue;
133
+ }
134
+
135
+ let config_path = entry.path().join(".handoff/config.toml");
136
+ if !config_path.exists() {
137
+ continue;
138
+ }
139
+
140
+ if let Ok(config) = read_config(&config_path) {
141
+ if config.project.name == name {
142
+ return Ok(entry.path());
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ anyhow::bail!(
149
+ "Target project '{name}' not found in scan_dirs. \
150
+ Use 'target_project_dir' with an absolute path instead."
151
+ )
152
+ }
@@ -0,0 +1,55 @@
1
+ use anyhow::{Context, Result};
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::referrals::{
7
+ change_referral_status, is_valid_referral_status, read_referral_summaries,
8
+ };
9
+
10
+ pub fn handle_list(arguments: &Value) -> Result<String> {
11
+ let project_dir = resolve_project_dir(arguments)?;
12
+ let handoff = ensure_handoff_exists(&project_dir)?;
13
+ let referrals_dir = handoff.join("referrals");
14
+
15
+ let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
16
+
17
+ if let Some(filter) = status_filter {
18
+ if !is_valid_referral_status(filter) {
19
+ anyhow::bail!(
20
+ "Invalid status_filter: '{filter}'. Must be one of: open, acknowledged, resolved"
21
+ );
22
+ }
23
+ }
24
+
25
+ let summaries = read_referral_summaries(&referrals_dir, status_filter)?;
26
+
27
+ let result = serde_json::json!({
28
+ "referrals": summaries,
29
+ "total": summaries.len(),
30
+ });
31
+
32
+ serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
33
+ }
34
+
35
+ pub fn handle_update(arguments: &Value) -> Result<String> {
36
+ let project_dir = resolve_project_dir(arguments)?;
37
+ let handoff = ensure_handoff_exists(&project_dir)?;
38
+ let referrals_dir = handoff.join("referrals");
39
+
40
+ let referral_id = arguments
41
+ .get("referral_id")
42
+ .and_then(|v| v.as_str())
43
+ .ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
44
+
45
+ let status = arguments
46
+ .get("status")
47
+ .and_then(|v| v.as_str())
48
+ .ok_or_else(|| anyhow::anyhow!("'status' is required"))?;
49
+
50
+ change_referral_status(&referrals_dir, referral_id, status)?;
51
+
52
+ Ok(format!(
53
+ "Updated referral {referral_id}: status -> {status}"
54
+ ))
55
+ }
@@ -16,11 +16,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
16
16
  .get("task")
17
17
  .ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
18
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
19
  let task_id = task_val.get("id").and_then(|v| v.as_str());
25
20
  let move_to = arguments.get("move_to").and_then(|v| v.as_str());
26
21
 
@@ -31,6 +26,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
31
26
  return handle_update(&tasks_dir, existing_id, task_val);
32
27
  }
33
28
 
29
+ let title = task_val
30
+ .get("title")
31
+ .and_then(|v| v.as_str())
32
+ .ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
33
+
34
34
  handle_create(&tasks_dir, title, task_val, arguments)
35
35
  }
36
36
 
@@ -71,6 +71,9 @@ fn handle_create(
71
71
  anyhow::bail!("Invalid status: {status}");
72
72
  }
73
73
 
74
+ let priority = task_val.get("priority").and_then(|v| v.as_str());
75
+ validate_priority(priority)?;
76
+
74
77
  let data = TaskData {
75
78
  id: new_id.clone(),
76
79
  title: title.to_string(),
@@ -78,10 +81,7 @@ fn handle_create(
78
81
  .get("notes")
79
82
  .and_then(|v| v.as_str())
80
83
  .map(String::from),
81
- priority: task_val
82
- .get("priority")
83
- .and_then(|v| v.as_str())
84
- .map(String::from),
84
+ priority: priority.map(String::from),
85
85
  created_at: Some(now.clone()),
86
86
  updated_at: Some(now),
87
87
  completed_at: None,
@@ -109,6 +109,7 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
109
109
  data.notes = Some(notes.to_string());
110
110
  }
111
111
  if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
112
+ validate_priority(Some(priority))?;
112
113
  data.priority = Some(priority.to_string());
113
114
  }
114
115
  if task_val.get("labels").is_some() {
package/src/mcp/tools.rs CHANGED
@@ -156,6 +156,50 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
156
156
  }
157
157
  }),
158
158
  },
159
+ ToolDefinition {
160
+ name: "handoff_get_task".to_string(),
161
+ description: "Get full task details (notes, done_criteria, labels, links) by task ID. Use when list_tasks summary is not enough.".to_string(),
162
+ input_schema: json!({
163
+ "type": "object",
164
+ "properties": {
165
+ "project_dir": {
166
+ "type": "string",
167
+ "description": "Project directory path. Defaults to current working directory."
168
+ },
169
+ "task_id": {
170
+ "type": "string",
171
+ "description": "Task ID to retrieve (e.g. 't1', 't1.2')."
172
+ }
173
+ },
174
+ "required": ["task_id"]
175
+ }),
176
+ },
177
+ ToolDefinition {
178
+ name: "handoff_check_criterion".to_string(),
179
+ description: "Toggle a single done_criteria item by index. No need to resend the entire criteria list.".to_string(),
180
+ input_schema: json!({
181
+ "type": "object",
182
+ "properties": {
183
+ "project_dir": {
184
+ "type": "string",
185
+ "description": "Project directory path. Defaults to current working directory."
186
+ },
187
+ "task_id": {
188
+ "type": "string",
189
+ "description": "Task ID containing the criterion."
190
+ },
191
+ "criterion_index": {
192
+ "type": "integer",
193
+ "description": "0-based index of the done_criteria item to toggle."
194
+ },
195
+ "checked": {
196
+ "type": "boolean",
197
+ "description": "true to mark as checked, false to uncheck."
198
+ }
199
+ },
200
+ "required": ["task_id", "criterion_index", "checked"]
201
+ }),
202
+ },
159
203
  ToolDefinition {
160
204
  name: "handoff_update_task".to_string(),
161
205
  description: "Add, update, or move a task. Manages the tasks/ directory structure.".to_string(),
@@ -170,7 +214,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
170
214
  "type": "object",
171
215
  "properties": {
172
216
  "id": { "type": "string", "description": "Task ID. Omit for new task (auto-generated)." },
173
- "title": { "type": "string" },
217
+ "title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
174
218
  "status": {
175
219
  "type": "string",
176
220
  "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
@@ -200,7 +244,6 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
200
244
  }
201
245
  }
202
246
  },
203
- "required": ["title"]
204
247
  },
205
248
  "parent_id": {
206
249
  "type": "string",
@@ -263,6 +306,278 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
263
306
  }
264
307
  }),
265
308
  },
309
+ ToolDefinition {
310
+ name: "handoff_import_context".to_string(),
311
+ description: "Import existing handoff documents into .handoff/ management. AI reads the source material, structures it, and submits everything in one call. Supports nested task hierarchies via children field.".to_string(),
312
+ input_schema: json!({
313
+ "type": "object",
314
+ "properties": {
315
+ "project_dir": {
316
+ "type": "string",
317
+ "description": "Project directory path. Defaults to current working directory."
318
+ },
319
+ "source": {
320
+ "type": "object",
321
+ "description": "Metadata about the original document being imported",
322
+ "properties": {
323
+ "description": {
324
+ "type": "string",
325
+ "description": "What is being imported (e.g. 'Migration from tmp/260601-sprint-handoff.md')"
326
+ },
327
+ "format": {
328
+ "type": "string",
329
+ "enum": ["markdown", "json", "text", "other"],
330
+ "description": "Format of the source material. Defaults to 'other'."
331
+ }
332
+ },
333
+ "required": ["description"]
334
+ },
335
+ "tasks": {
336
+ "type": "array",
337
+ "description": "Tasks to import. Supports nested hierarchies via children field.",
338
+ "items": {
339
+ "$ref": "#/$defs/importTask"
340
+ }
341
+ },
342
+ "session": {
343
+ "type": "object",
344
+ "description": "Session context to save. Same fields as handoff_save_context.",
345
+ "properties": {
346
+ "summary": { "type": "string", "description": "One-line summary (required)" },
347
+ "decisions": {
348
+ "type": "array",
349
+ "items": {
350
+ "type": "object",
351
+ "properties": {
352
+ "decision": { "type": "string" },
353
+ "reason": { "type": "string" },
354
+ "confidence": {
355
+ "type": "string",
356
+ "enum": ["confirmed", "estimated", "unverified"]
357
+ }
358
+ },
359
+ "required": ["decision"]
360
+ }
361
+ },
362
+ "blockers": {
363
+ "type": "array",
364
+ "items": { "type": "string" }
365
+ },
366
+ "checklist": {
367
+ "type": "array",
368
+ "items": {
369
+ "type": "object",
370
+ "properties": {
371
+ "item": { "type": "string" },
372
+ "checked": { "type": "boolean" },
373
+ "owner": { "type": "string", "enum": ["user", "ai"] }
374
+ },
375
+ "required": ["item"]
376
+ }
377
+ },
378
+ "handoff_notes": {
379
+ "type": "array",
380
+ "items": {
381
+ "type": "object",
382
+ "properties": {
383
+ "note": { "type": "string" },
384
+ "category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
385
+ },
386
+ "required": ["note"]
387
+ }
388
+ },
389
+ "references": {
390
+ "type": "array",
391
+ "items": {
392
+ "type": "object",
393
+ "properties": {
394
+ "label": { "type": "string" },
395
+ "uri": { "type": "string" },
396
+ "type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
397
+ "notes": { "type": "string" }
398
+ },
399
+ "required": ["label", "uri"]
400
+ }
401
+ },
402
+ "context_pointers": {
403
+ "type": "array",
404
+ "items": {
405
+ "type": "object",
406
+ "properties": {
407
+ "path": { "type": "string" },
408
+ "reason": { "type": "string" },
409
+ "lines": { "type": "string" }
410
+ },
411
+ "required": ["path"]
412
+ }
413
+ },
414
+ "environment": {
415
+ "type": "object",
416
+ "description": "Free-form environment state"
417
+ }
418
+ },
419
+ "required": ["summary"]
420
+ },
421
+ "raw_notes": {
422
+ "type": "string",
423
+ "description": "Free-form text that couldn't be structured. Saved as a handoff_note with category 'context'."
424
+ }
425
+ },
426
+ "required": ["source"],
427
+ "$defs": {
428
+ "importTask": {
429
+ "type": "object",
430
+ "properties": {
431
+ "title": { "type": "string" },
432
+ "status": {
433
+ "type": "string",
434
+ "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
435
+ },
436
+ "notes": { "type": "string" },
437
+ "priority": {
438
+ "type": "string",
439
+ "enum": ["low", "medium", "high"]
440
+ },
441
+ "labels": {
442
+ "type": "array",
443
+ "items": { "type": "string" }
444
+ },
445
+ "links": {
446
+ "type": "array",
447
+ "items": { "type": "string" }
448
+ },
449
+ "done_criteria": {
450
+ "type": "array",
451
+ "items": {
452
+ "type": "object",
453
+ "properties": {
454
+ "item": { "type": "string" },
455
+ "checked": { "type": "boolean" }
456
+ },
457
+ "required": ["item"]
458
+ }
459
+ },
460
+ "children": {
461
+ "type": "array",
462
+ "description": "Nested child tasks. Recursively supports the same structure.",
463
+ "items": {
464
+ "$ref": "#/$defs/importTask"
465
+ }
466
+ }
467
+ },
468
+ "required": ["title"]
469
+ }
470
+ }
471
+ }),
472
+ },
473
+ ToolDefinition {
474
+ name: "handoff_refer".to_string(),
475
+ description: "Send a cross-project referral (improvement request, bug report, work request) to another project's .handoff/. The target project sees it on load_context.".to_string(),
476
+ input_schema: json!({
477
+ "type": "object",
478
+ "properties": {
479
+ "project_dir": {
480
+ "type": "string",
481
+ "description": "Source project directory (sender). Defaults to current working directory."
482
+ },
483
+ "target_project": {
484
+ "type": "string",
485
+ "description": "Target project name (resolved via scan_dirs). Use this OR target_project_dir."
486
+ },
487
+ "target_project_dir": {
488
+ "type": "string",
489
+ "description": "Target project directory path (absolute). Takes precedence over target_project."
490
+ },
491
+ "referral_type": {
492
+ "type": "string",
493
+ "enum": ["improvement", "bug", "request", "info"],
494
+ "description": "Type of referral. Defaults to 'request'."
495
+ },
496
+ "summary": {
497
+ "type": "string",
498
+ "description": "One-line summary of the referral."
499
+ },
500
+ "details": {
501
+ "type": "string",
502
+ "description": "Detailed description of the referral."
503
+ },
504
+ "priority": {
505
+ "type": "string",
506
+ "enum": ["low", "medium", "high"],
507
+ "description": "Priority of the referral."
508
+ },
509
+ "tasks": {
510
+ "type": "array",
511
+ "description": "Suggested tasks for the target project.",
512
+ "items": {
513
+ "type": "object",
514
+ "properties": {
515
+ "title": { "type": "string" },
516
+ "priority": { "type": "string", "enum": ["low", "medium", "high"] },
517
+ "done_criteria": {
518
+ "type": "array",
519
+ "items": {
520
+ "type": "object",
521
+ "properties": {
522
+ "item": { "type": "string" },
523
+ "checked": { "type": "boolean" }
524
+ },
525
+ "required": ["item"]
526
+ }
527
+ }
528
+ },
529
+ "required": ["title"]
530
+ }
531
+ },
532
+ "context": {
533
+ "type": "object",
534
+ "description": "Additional context (branch, commit, references)."
535
+ }
536
+ },
537
+ "required": ["summary"]
538
+ }),
539
+ },
540
+ ToolDefinition {
541
+ name: "handoff_list_referrals".to_string(),
542
+ description: "List incoming referrals from other projects with optional status filter.".to_string(),
543
+ input_schema: json!({
544
+ "type": "object",
545
+ "properties": {
546
+ "project_dir": {
547
+ "type": "string",
548
+ "description": "Project directory path. Defaults to current working directory."
549
+ },
550
+ "status_filter": {
551
+ "type": "string",
552
+ "enum": ["open", "acknowledged", "resolved"],
553
+ "description": "Filter by referral status."
554
+ }
555
+ }
556
+ }),
557
+ },
558
+ ToolDefinition {
559
+ name: "handoff_update_referral".to_string(),
560
+ description: "Update the status of an incoming referral (open -> acknowledged -> resolved).".to_string(),
561
+ input_schema: json!({
562
+ "type": "object",
563
+ "properties": {
564
+ "project_dir": {
565
+ "type": "string",
566
+ "description": "Project directory path. Defaults to current working directory."
567
+ },
568
+ "referral_id": {
569
+ "type": "string",
570
+ "description": "ID of the referral to update."
571
+ },
572
+ "status": {
573
+ "type": "string",
574
+ "enum": ["open", "acknowledged", "resolved"],
575
+ "description": "New status for the referral."
576
+ }
577
+ },
578
+ "required": ["referral_id", "status"]
579
+ }),
580
+ },
266
581
  ]
267
582
  }
268
583
 
@@ -1,5 +1,6 @@
1
1
  pub mod config;
2
2
  pub mod git;
3
+ pub mod referrals;
3
4
  pub mod sessions;
4
5
  pub mod tasks;
5
6
 
@@ -7,6 +8,15 @@ use std::path::{Path, PathBuf};
7
8
 
8
9
  use anyhow::{Context, Result};
9
10
 
11
+ pub fn expand_tilde(path: &str) -> String {
12
+ if let Some(rest) = path.strip_prefix("~/") {
13
+ if let Ok(home) = std::env::var("HOME") {
14
+ return format!("{home}/{rest}");
15
+ }
16
+ }
17
+ path.to_string()
18
+ }
19
+
10
20
  pub fn handoff_dir(project_dir: &Path) -> PathBuf {
11
21
  project_dir.join(".handoff")
12
22
  }