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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +12 -6
- package/package.json +1 -1
- package/src/mcp/handlers/check_criterion.rs +68 -0
- package/src/mcp/handlers/dashboard.rs +7 -9
- package/src/mcp/handlers/get_task.rs +40 -0
- package/src/mcp/handlers/import_context.rs +310 -0
- package/src/mcp/handlers/load_context.rs +7 -0
- package/src/mcp/handlers/mod.rs +11 -0
- package/src/mcp/handlers/refer.rs +152 -0
- package/src/mcp/handlers/referrals.rs +55 -0
- package/src/mcp/handlers/update_task.rs +10 -9
- package/src/mcp/tools.rs +317 -2
- package/src/storage/mod.rs +10 -0
- package/src/storage/referrals.rs +210 -0
- package/src/storage/sessions.rs +9 -2
- package/src/storage/tasks.rs +27 -2
- package/tests/mcp_protocol.rs +4 -0
- package/tests/storage_tasks.rs +34 -0
- package/tests/tool_import_context.rs +443 -0
- package/tests/tool_referrals.rs +442 -0
- package/tests/tool_tasks.rs +357 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use serde::{Deserialize, Serialize};
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
const VALID_REFERRAL_TYPES: &[&str] = &["improvement", "bug", "request", "info"];
|
|
8
|
+
const VALID_REFERRAL_STATUSES: &[&str] = &["open", "acknowledged", "resolved"];
|
|
9
|
+
|
|
10
|
+
pub fn is_valid_referral_type(t: &str) -> bool {
|
|
11
|
+
VALID_REFERRAL_TYPES.contains(&t)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub fn is_valid_referral_status(s: &str) -> bool {
|
|
15
|
+
VALID_REFERRAL_STATUSES.contains(&s)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
19
|
+
pub struct ReferralData {
|
|
20
|
+
pub id: String,
|
|
21
|
+
pub source_project: String,
|
|
22
|
+
pub source_project_dir: String,
|
|
23
|
+
pub created_at: String,
|
|
24
|
+
pub referral_type: String,
|
|
25
|
+
pub summary: String,
|
|
26
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
27
|
+
pub details: Option<String>,
|
|
28
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
+
pub priority: Option<String>,
|
|
30
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
+
pub tasks: Vec<Value>,
|
|
32
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
|
+
pub context: Option<Value>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
37
|
+
pub struct ReferralSummary {
|
|
38
|
+
pub id: String,
|
|
39
|
+
pub source_project: String,
|
|
40
|
+
pub referral_type: String,
|
|
41
|
+
pub summary: String,
|
|
42
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
43
|
+
pub priority: Option<String>,
|
|
44
|
+
pub status: String,
|
|
45
|
+
pub created_at: String,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathBuf> {
|
|
49
|
+
std::fs::create_dir_all(referrals_dir).with_context(|| {
|
|
50
|
+
format!(
|
|
51
|
+
"Failed to create referrals dir: {}",
|
|
52
|
+
referrals_dir.display()
|
|
53
|
+
)
|
|
54
|
+
})?;
|
|
55
|
+
|
|
56
|
+
let slug = source_to_slug(&data.source_project);
|
|
57
|
+
let id_suffix = data.id.replace("ref-", "");
|
|
58
|
+
let filename = format!("{id_suffix}-{slug}.open.json");
|
|
59
|
+
let file_path = referrals_dir.join(&filename);
|
|
60
|
+
|
|
61
|
+
let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
|
|
62
|
+
std::fs::write(&file_path, content)
|
|
63
|
+
.with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
|
|
64
|
+
|
|
65
|
+
Ok(file_path)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn read_referrals(
|
|
69
|
+
referrals_dir: &Path,
|
|
70
|
+
status_filter: Option<&str>,
|
|
71
|
+
) -> Result<Vec<(ReferralData, String)>> {
|
|
72
|
+
let mut results = Vec::new();
|
|
73
|
+
|
|
74
|
+
if !referrals_dir.exists() {
|
|
75
|
+
return Ok(results);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let mut entries: Vec<_> = std::fs::read_dir(referrals_dir)?
|
|
79
|
+
.filter_map(|e| e.ok())
|
|
80
|
+
.collect();
|
|
81
|
+
entries.sort_by_key(|e| e.file_name());
|
|
82
|
+
|
|
83
|
+
for entry in entries {
|
|
84
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
85
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
86
|
+
if let Some(filter) = status_filter {
|
|
87
|
+
if status != filter {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let content = std::fs::read_to_string(entry.path())
|
|
92
|
+
.with_context(|| format!("Failed to read referral: {}", entry.path().display()))?;
|
|
93
|
+
let data: ReferralData = serde_json::from_str(&content)
|
|
94
|
+
.with_context(|| format!("Failed to parse referral: {}", entry.path().display()))?;
|
|
95
|
+
results.push((data, status));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Ok(results)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pub fn read_referral_summaries(
|
|
103
|
+
referrals_dir: &Path,
|
|
104
|
+
status_filter: Option<&str>,
|
|
105
|
+
) -> Result<Vec<ReferralSummary>> {
|
|
106
|
+
let referrals = read_referrals(referrals_dir, status_filter)?;
|
|
107
|
+
Ok(referrals
|
|
108
|
+
.into_iter()
|
|
109
|
+
.map(|(data, status)| ReferralSummary {
|
|
110
|
+
id: data.id,
|
|
111
|
+
source_project: data.source_project,
|
|
112
|
+
referral_type: data.referral_type,
|
|
113
|
+
summary: data.summary,
|
|
114
|
+
priority: data.priority,
|
|
115
|
+
status,
|
|
116
|
+
created_at: data.created_at,
|
|
117
|
+
})
|
|
118
|
+
.collect())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub fn change_referral_status(
|
|
122
|
+
referrals_dir: &Path,
|
|
123
|
+
referral_id: &str,
|
|
124
|
+
new_status: &str,
|
|
125
|
+
) -> Result<()> {
|
|
126
|
+
if !is_valid_referral_status(new_status) {
|
|
127
|
+
anyhow::bail!(
|
|
128
|
+
"Invalid referral status: '{new_status}'. Must be one of: {}",
|
|
129
|
+
VALID_REFERRAL_STATUSES.join(", ")
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let (old_path, old_status) = find_referral_file(referrals_dir, referral_id)?
|
|
134
|
+
.ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
|
|
135
|
+
|
|
136
|
+
if old_status == new_status {
|
|
137
|
+
return Ok(());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let old_name = old_path
|
|
141
|
+
.file_name()
|
|
142
|
+
.ok_or_else(|| anyhow::anyhow!("Invalid referral path"))?
|
|
143
|
+
.to_string_lossy()
|
|
144
|
+
.to_string();
|
|
145
|
+
|
|
146
|
+
let new_name = old_name.replace(
|
|
147
|
+
&format!(".{old_status}.json"),
|
|
148
|
+
&format!(".{new_status}.json"),
|
|
149
|
+
);
|
|
150
|
+
let new_path = referrals_dir.join(&new_name);
|
|
151
|
+
|
|
152
|
+
std::fs::rename(&old_path, &new_path).with_context(|| {
|
|
153
|
+
format!(
|
|
154
|
+
"Failed to rename {} -> {}",
|
|
155
|
+
old_path.display(),
|
|
156
|
+
new_path.display()
|
|
157
|
+
)
|
|
158
|
+
})?;
|
|
159
|
+
|
|
160
|
+
Ok(())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub fn find_referral_file(
|
|
164
|
+
referrals_dir: &Path,
|
|
165
|
+
referral_id: &str,
|
|
166
|
+
) -> Result<Option<(PathBuf, String)>> {
|
|
167
|
+
if !referrals_dir.exists() {
|
|
168
|
+
return Ok(None);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for entry in std::fs::read_dir(referrals_dir)? {
|
|
172
|
+
let entry = entry?;
|
|
173
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
174
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
175
|
+
let content = std::fs::read_to_string(entry.path())?;
|
|
176
|
+
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
177
|
+
if data.id == referral_id {
|
|
178
|
+
return Ok(Some((entry.path(), status)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
Ok(None)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fn parse_referral_status(filename: &str) -> Option<String> {
|
|
188
|
+
let name = filename.strip_suffix(".json")?;
|
|
189
|
+
for status in VALID_REFERRAL_STATUSES {
|
|
190
|
+
if name.ends_with(&format!(".{status}")) {
|
|
191
|
+
return Some(status.to_string());
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
None
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn source_to_slug(source: &str) -> String {
|
|
198
|
+
let slug: String = source
|
|
199
|
+
.chars()
|
|
200
|
+
.take(30)
|
|
201
|
+
.map(|c| {
|
|
202
|
+
if c.is_ascii_alphanumeric() {
|
|
203
|
+
c.to_ascii_lowercase()
|
|
204
|
+
} else {
|
|
205
|
+
'-'
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.collect();
|
|
209
|
+
slug.trim_matches('-').to_string()
|
|
210
|
+
}
|
package/src/storage/sessions.rs
CHANGED
|
@@ -39,10 +39,17 @@ pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
|
|
|
39
39
|
|
|
40
40
|
fn summary_to_slug(summary: &str) -> String {
|
|
41
41
|
let slug: String = summary
|
|
42
|
-
.to_lowercase()
|
|
43
42
|
.chars()
|
|
44
43
|
.take(40)
|
|
45
|
-
.map(|c|
|
|
44
|
+
.map(|c| {
|
|
45
|
+
if c.is_ascii_alphanumeric() {
|
|
46
|
+
c.to_ascii_lowercase()
|
|
47
|
+
} else if !c.is_ascii() {
|
|
48
|
+
c
|
|
49
|
+
} else {
|
|
50
|
+
'-'
|
|
51
|
+
}
|
|
52
|
+
})
|
|
46
53
|
.collect();
|
|
47
54
|
let slug = slug.trim_matches('-').to_string();
|
|
48
55
|
let mut result = String::new();
|
package/src/storage/tasks.rs
CHANGED
|
@@ -56,19 +56,44 @@ const VALID_STATUSES: &[&str] = &[
|
|
|
56
56
|
"skipped",
|
|
57
57
|
];
|
|
58
58
|
|
|
59
|
+
const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
|
|
60
|
+
|
|
59
61
|
pub fn is_valid_status(status: &str) -> bool {
|
|
60
62
|
VALID_STATUSES.contains(&status)
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
pub fn is_valid_priority(priority: &str) -> bool {
|
|
66
|
+
VALID_PRIORITIES.contains(&priority)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub fn validate_priority(priority: Option<&str>) -> Result<()> {
|
|
70
|
+
if let Some(p) = priority {
|
|
71
|
+
if !is_valid_priority(p) {
|
|
72
|
+
anyhow::bail!(
|
|
73
|
+
"Invalid priority: '{p}'. Must be one of: {}",
|
|
74
|
+
VALID_PRIORITIES.join(", ")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
Ok(())
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
pub fn is_terminal_status(status: &str) -> bool {
|
|
64
82
|
status == "done" || status == "skipped"
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
pub fn title_to_slug(title: &str) -> String {
|
|
68
86
|
let slug: String = title
|
|
69
|
-
.to_lowercase()
|
|
70
87
|
.chars()
|
|
71
|
-
.map(|c|
|
|
88
|
+
.map(|c| {
|
|
89
|
+
if c.is_ascii_alphanumeric() {
|
|
90
|
+
c.to_ascii_lowercase()
|
|
91
|
+
} else if !c.is_ascii() {
|
|
92
|
+
c
|
|
93
|
+
} else {
|
|
94
|
+
'-'
|
|
95
|
+
}
|
|
96
|
+
})
|
|
72
97
|
.collect();
|
|
73
98
|
let slug = slug.trim_matches('-').to_string();
|
|
74
99
|
let mut result = String::new();
|
package/tests/mcp_protocol.rs
CHANGED
|
@@ -55,6 +55,10 @@ fn tools_list_returns_all_tools() {
|
|
|
55
55
|
"handoff_get_config",
|
|
56
56
|
"handoff_update_config",
|
|
57
57
|
"handoff_dashboard",
|
|
58
|
+
"handoff_import_context",
|
|
59
|
+
"handoff_refer",
|
|
60
|
+
"handoff_list_referrals",
|
|
61
|
+
"handoff_update_referral",
|
|
58
62
|
];
|
|
59
63
|
|
|
60
64
|
let tool_names: Vec<&str> = tools
|
package/tests/storage_tasks.rs
CHANGED
|
@@ -300,3 +300,37 @@ fn validate_skipped_child_not_terminal_fails() {
|
|
|
300
300
|
let data = make_task("t1", "Parent");
|
|
301
301
|
assert!(validate_skipped_transition(&task_dir, &data).is_err());
|
|
302
302
|
}
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn is_valid_priority_accepts_valid() {
|
|
306
|
+
assert!(is_valid_priority("low"));
|
|
307
|
+
assert!(is_valid_priority("medium"));
|
|
308
|
+
assert!(is_valid_priority("high"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn is_valid_priority_rejects_invalid() {
|
|
313
|
+
assert!(!is_valid_priority("critical"));
|
|
314
|
+
assert!(!is_valid_priority("urgent"));
|
|
315
|
+
assert!(!is_valid_priority(""));
|
|
316
|
+
assert!(!is_valid_priority("HIGH"));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn validate_priority_none_is_ok() {
|
|
321
|
+
assert!(validate_priority(None).is_ok());
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#[test]
|
|
325
|
+
fn validate_priority_valid_is_ok() {
|
|
326
|
+
assert!(validate_priority(Some("low")).is_ok());
|
|
327
|
+
assert!(validate_priority(Some("medium")).is_ok());
|
|
328
|
+
assert!(validate_priority(Some("high")).is_ok());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[test]
|
|
332
|
+
fn validate_priority_invalid_is_err() {
|
|
333
|
+
let err = validate_priority(Some("critical")).unwrap_err();
|
|
334
|
+
assert!(err.to_string().contains("Invalid priority"));
|
|
335
|
+
assert!(err.to_string().contains("critical"));
|
|
336
|
+
}
|