handoff-mcp-server 0.6.0 → 0.6.2
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/bin/handoff-mcp-bin +0 -0
- package/bin/handoff-mcp.js +0 -0
- package/package.json +1 -1
- package/src/mcp/handlers/refer.rs +157 -2
- package/src/mcp/handlers/save_context.rs +49 -2
- package/tests/tool_referrals.rs +162 -0
- package/tests/tool_sessions.rs +2 -2
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
Binary file
|
package/bin/handoff-mcp.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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>",
|
|
@@ -93,10 +93,165 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
93
93
|
target_dir.to_string_lossy().to_string()
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
let mut msg = format!(
|
|
97
97
|
"Referral sent: {id}\n From: {}\n To: {target_name}\n Type: {referral_type}\n Summary: {summary}",
|
|
98
98
|
source_config.project.name
|
|
99
|
-
)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for w in collect_refer_warnings(&data, &source_project_dir, &target_dir) {
|
|
102
|
+
msg.push_str(&format!("\n{w}"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
Ok(msg)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn collect_refer_warnings(
|
|
109
|
+
data: &ReferralData,
|
|
110
|
+
source_dir: &Path,
|
|
111
|
+
target_dir: &Path,
|
|
112
|
+
) -> Vec<String> {
|
|
113
|
+
let mut warnings = Vec::new();
|
|
114
|
+
|
|
115
|
+
if data.details.is_none() {
|
|
116
|
+
warnings.push(
|
|
117
|
+
"Warning: No details. The target project won't know what specifically to do. \
|
|
118
|
+
Add a 'details' field describing the change, its impact, and what needs updating."
|
|
119
|
+
.to_string(),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if data.tasks.is_empty() {
|
|
124
|
+
warnings.push(
|
|
125
|
+
"Warning: No tasks. Consider adding suggested tasks with done_criteria \
|
|
126
|
+
so the target project has actionable items to work from."
|
|
127
|
+
.to_string(),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if let Some(ref ctx) = data.context {
|
|
132
|
+
let refs = collect_refs_from_value(ctx);
|
|
133
|
+
if refs.is_empty() {
|
|
134
|
+
warnings.push(
|
|
135
|
+
"Warning: context has no spec/doc references. Add 'spec_docs' with wiki paths, \
|
|
136
|
+
MR URLs, or file paths so the target can find the authoritative specification."
|
|
137
|
+
.to_string(),
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
for r in &refs {
|
|
141
|
+
if r.starts_with("http://") || r.starts_with("https://") {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let clean = r.split(" — ").next().unwrap_or(r).trim();
|
|
145
|
+
let clean = clean.split('#').next().unwrap_or(clean).trim();
|
|
146
|
+
let p = Path::new(clean);
|
|
147
|
+
if p.is_absolute() {
|
|
148
|
+
if !p.exists() {
|
|
149
|
+
warnings.push(format!(
|
|
150
|
+
"Warning: spec reference path does not exist: {clean}"
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
let in_source = source_dir.join(clean);
|
|
155
|
+
let in_target = target_dir.join(clean);
|
|
156
|
+
if !in_source.exists() && !in_target.exists() {
|
|
157
|
+
warnings.push(format!(
|
|
158
|
+
"Warning: spec reference path does not exist \
|
|
159
|
+
in source or target project: {clean}"
|
|
160
|
+
));
|
|
161
|
+
} else if !in_target.exists() {
|
|
162
|
+
warnings.push(format!(
|
|
163
|
+
"Warning: spec reference '{clean}' exists in source project \
|
|
164
|
+
but not in target project. Use an absolute path or ensure \
|
|
165
|
+
the target has this file."
|
|
166
|
+
));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
warnings.push(
|
|
173
|
+
"Warning: No context. Add a 'context' field with 'spec_docs' referencing the \
|
|
174
|
+
authoritative specification (wiki paths, MR URLs, source file paths)."
|
|
175
|
+
.to_string(),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if data.priority.is_none() {
|
|
180
|
+
warnings.push(
|
|
181
|
+
"Warning: No priority. Set 'priority' (low/medium/high) so the target project \
|
|
182
|
+
can triage this referral appropriately."
|
|
183
|
+
.to_string(),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (i, task) in data.tasks.iter().enumerate() {
|
|
188
|
+
let has_criteria = task
|
|
189
|
+
.get("done_criteria")
|
|
190
|
+
.and_then(|v| v.as_array())
|
|
191
|
+
.is_some_and(|a| !a.is_empty());
|
|
192
|
+
if !has_criteria {
|
|
193
|
+
let title = task
|
|
194
|
+
.get("title")
|
|
195
|
+
.and_then(|v| v.as_str())
|
|
196
|
+
.unwrap_or("(untitled)");
|
|
197
|
+
warnings.push(format!(
|
|
198
|
+
"Warning: Task #{} '{}' has no done_criteria. \
|
|
199
|
+
Add criteria so the target knows when the task is complete.",
|
|
200
|
+
i + 1,
|
|
201
|
+
title
|
|
202
|
+
));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
warnings
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fn is_ref_string(s: &str) -> bool {
|
|
210
|
+
s.starts_with("http://")
|
|
211
|
+
|| s.starts_with("https://")
|
|
212
|
+
|| s.starts_with('/')
|
|
213
|
+
|| s.starts_with("wiki/")
|
|
214
|
+
|| s.starts_with("docs/")
|
|
215
|
+
|| s.starts_with("src/")
|
|
216
|
+
|| s.ends_with(".md")
|
|
217
|
+
|| s.ends_with(".rs")
|
|
218
|
+
|| s.ends_with(".ts")
|
|
219
|
+
|| s.ends_with(".toml")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fn collect_refs_from_value(val: &Value) -> Vec<String> {
|
|
223
|
+
let mut refs = Vec::new();
|
|
224
|
+
match val {
|
|
225
|
+
Value::String(s) => {
|
|
226
|
+
if is_ref_string(s) {
|
|
227
|
+
refs.push(s.clone());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
Value::Array(arr) => {
|
|
231
|
+
for item in arr {
|
|
232
|
+
refs.extend(collect_refs_from_value(item));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
Value::Object(map) => {
|
|
236
|
+
for (key, v) in map {
|
|
237
|
+
if key == "spec_docs"
|
|
238
|
+
|| key == "source_wiki"
|
|
239
|
+
|| key == "source_data_model"
|
|
240
|
+
|| key.contains("spec")
|
|
241
|
+
|| key.contains("doc")
|
|
242
|
+
|| key.contains("wiki")
|
|
243
|
+
{
|
|
244
|
+
refs.extend(collect_refs_from_value(v));
|
|
245
|
+
} else if let Value::String(s) = v {
|
|
246
|
+
if is_ref_string(s) {
|
|
247
|
+
refs.push(s.clone());
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
_ => {}
|
|
253
|
+
}
|
|
254
|
+
refs
|
|
100
255
|
}
|
|
101
256
|
|
|
102
257
|
fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
1
3
|
use anyhow::Result;
|
|
2
4
|
use chrono::Utc;
|
|
3
5
|
use serde_json::Value;
|
|
@@ -95,14 +97,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
95
97
|
));
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
for w in collect_save_warnings(&data) {
|
|
100
|
+
for w in collect_save_warnings(&data, &project_dir) {
|
|
99
101
|
msg.push_str(&format!("\n{w}"));
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
Ok(msg)
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
fn collect_save_warnings(data: &SessionData) -> Vec<String> {
|
|
107
|
+
fn collect_save_warnings(data: &SessionData, project_dir: &Path) -> Vec<String> {
|
|
106
108
|
let mut warnings = Vec::new();
|
|
107
109
|
|
|
108
110
|
if data.checklist.is_empty() {
|
|
@@ -169,6 +171,51 @@ fn collect_save_warnings(data: &SessionData) -> Vec<String> {
|
|
|
169
171
|
"Warning: No references. Consider adding links to relevant docs, issues, or MRs."
|
|
170
172
|
.to_string(),
|
|
171
173
|
);
|
|
174
|
+
} else {
|
|
175
|
+
for r in &data.references {
|
|
176
|
+
let uri = r.get("uri").and_then(|v| v.as_str()).unwrap_or("");
|
|
177
|
+
if uri.is_empty() {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if uri.starts_with("http://") || uri.starts_with("https://") {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if uri.starts_with("ref-") {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
let resolved = if Path::new(uri).is_absolute() {
|
|
187
|
+
std::path::PathBuf::from(uri)
|
|
188
|
+
} else {
|
|
189
|
+
project_dir.join(uri)
|
|
190
|
+
};
|
|
191
|
+
let check_path = resolved
|
|
192
|
+
.to_string_lossy()
|
|
193
|
+
.split('#')
|
|
194
|
+
.next()
|
|
195
|
+
.unwrap_or("")
|
|
196
|
+
.to_string();
|
|
197
|
+
if !Path::new(&check_path).exists() {
|
|
198
|
+
let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
|
199
|
+
warnings.push(format!(
|
|
200
|
+
"Warning: reference '{label}' path does not exist: {uri}"
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for cp in &data.context_pointers {
|
|
207
|
+
let p = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
208
|
+
if p.is_empty() {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
let resolved = if Path::new(p).is_absolute() {
|
|
212
|
+
std::path::PathBuf::from(p)
|
|
213
|
+
} else {
|
|
214
|
+
project_dir.join(p)
|
|
215
|
+
};
|
|
216
|
+
if !resolved.exists() {
|
|
217
|
+
warnings.push(format!("Warning: context_pointer path does not exist: {p}"));
|
|
218
|
+
}
|
|
172
219
|
}
|
|
173
220
|
|
|
174
221
|
warnings
|
package/tests/tool_referrals.rs
CHANGED
|
@@ -440,3 +440,165 @@ fn no_referrals_dir_is_graceful() {
|
|
|
440
440
|
let parsed: Value = serde_json::from_str(&text).unwrap();
|
|
441
441
|
assert_eq!(parsed["total"], 0);
|
|
442
442
|
}
|
|
443
|
+
|
|
444
|
+
#[test]
|
|
445
|
+
fn refer_warns_on_missing_details() {
|
|
446
|
+
let (_base, proj_a, proj_b) = setup_two_projects();
|
|
447
|
+
|
|
448
|
+
let resp = call_tool(
|
|
449
|
+
"handoff_refer",
|
|
450
|
+
json!({
|
|
451
|
+
"project_dir": proj_a.to_string_lossy(),
|
|
452
|
+
"target_project_dir": proj_b.to_string_lossy(),
|
|
453
|
+
"summary": "Minimal referral"
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
458
|
+
let text = get_text(&resp);
|
|
459
|
+
assert!(text.contains("Referral sent"));
|
|
460
|
+
let warning_count = text.matches("Warning").count();
|
|
461
|
+
assert!(
|
|
462
|
+
warning_count >= 3,
|
|
463
|
+
"should have at least 3 warnings (details, tasks, context), got {warning_count}: {text}"
|
|
464
|
+
);
|
|
465
|
+
assert!(
|
|
466
|
+
text.contains("details"),
|
|
467
|
+
"should warn about missing details: {text}"
|
|
468
|
+
);
|
|
469
|
+
assert!(
|
|
470
|
+
text.contains("tasks"),
|
|
471
|
+
"should warn about missing tasks: {text}"
|
|
472
|
+
);
|
|
473
|
+
assert!(
|
|
474
|
+
text.contains("context"),
|
|
475
|
+
"should warn about missing context: {text}"
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[test]
|
|
480
|
+
fn refer_warns_on_tasks_without_done_criteria() {
|
|
481
|
+
let (_base, proj_a, proj_b) = setup_two_projects();
|
|
482
|
+
|
|
483
|
+
let resp = call_tool(
|
|
484
|
+
"handoff_refer",
|
|
485
|
+
json!({
|
|
486
|
+
"project_dir": proj_a.to_string_lossy(),
|
|
487
|
+
"target_project_dir": proj_b.to_string_lossy(),
|
|
488
|
+
"summary": "Referral with bare tasks",
|
|
489
|
+
"details": "Some details here",
|
|
490
|
+
"priority": "high",
|
|
491
|
+
"context": { "branch": "main" },
|
|
492
|
+
"tasks": [
|
|
493
|
+
{ "title": "Task without criteria" },
|
|
494
|
+
{ "title": "Task with criteria", "done_criteria": [{"item": "check", "checked": false}] }
|
|
495
|
+
]
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
500
|
+
let text = get_text(&resp);
|
|
501
|
+
assert!(
|
|
502
|
+
text.contains("Task #1 'Task without criteria' has no done_criteria"),
|
|
503
|
+
"should warn about task without criteria: {text}"
|
|
504
|
+
);
|
|
505
|
+
assert!(
|
|
506
|
+
!text.contains("Task #2"),
|
|
507
|
+
"should NOT warn about task with criteria: {text}"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#[test]
|
|
512
|
+
fn refer_no_warnings_when_complete() {
|
|
513
|
+
let (_base, proj_a, proj_b) = setup_two_projects();
|
|
514
|
+
|
|
515
|
+
let resp = call_tool(
|
|
516
|
+
"handoff_refer",
|
|
517
|
+
json!({
|
|
518
|
+
"project_dir": proj_a.to_string_lossy(),
|
|
519
|
+
"target_project_dir": proj_b.to_string_lossy(),
|
|
520
|
+
"summary": "Complete referral",
|
|
521
|
+
"referral_type": "request",
|
|
522
|
+
"priority": "high",
|
|
523
|
+
"details": "Full description of what needs to happen",
|
|
524
|
+
"context": {
|
|
525
|
+
"branch": "feat/x",
|
|
526
|
+
"commit": "abc123",
|
|
527
|
+
"spec_docs": [
|
|
528
|
+
format!("{}/.handoff/config.toml", proj_a.to_string_lossy()),
|
|
529
|
+
"https://gitlab.example.com/project/-/merge_requests/1"
|
|
530
|
+
]
|
|
531
|
+
},
|
|
532
|
+
"tasks": [
|
|
533
|
+
{
|
|
534
|
+
"title": "Do the thing",
|
|
535
|
+
"done_criteria": [{"item": "Thing is done", "checked": false}]
|
|
536
|
+
}
|
|
537
|
+
]
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
542
|
+
let text = get_text(&resp);
|
|
543
|
+
assert!(text.contains("Referral sent"));
|
|
544
|
+
assert!(
|
|
545
|
+
!text.contains("Warning"),
|
|
546
|
+
"should have no warnings when fully specified: {text}"
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#[test]
|
|
551
|
+
fn refer_warns_on_context_without_spec_docs() {
|
|
552
|
+
let (_base, proj_a, proj_b) = setup_two_projects();
|
|
553
|
+
|
|
554
|
+
let resp = call_tool(
|
|
555
|
+
"handoff_refer",
|
|
556
|
+
json!({
|
|
557
|
+
"project_dir": proj_a.to_string_lossy(),
|
|
558
|
+
"target_project_dir": proj_b.to_string_lossy(),
|
|
559
|
+
"summary": "Has context but no spec refs",
|
|
560
|
+
"details": "Description",
|
|
561
|
+
"priority": "medium",
|
|
562
|
+
"context": { "branch": "main", "commit": "abc123" },
|
|
563
|
+
"tasks": [
|
|
564
|
+
{ "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
|
|
565
|
+
]
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
570
|
+
let text = get_text(&resp);
|
|
571
|
+
assert!(
|
|
572
|
+
text.contains("spec/doc references"),
|
|
573
|
+
"should warn about missing spec references in context: {text}"
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#[test]
|
|
578
|
+
fn refer_warns_on_nonexistent_spec_path() {
|
|
579
|
+
let (_base, proj_a, proj_b) = setup_two_projects();
|
|
580
|
+
|
|
581
|
+
let resp = call_tool(
|
|
582
|
+
"handoff_refer",
|
|
583
|
+
json!({
|
|
584
|
+
"project_dir": proj_a.to_string_lossy(),
|
|
585
|
+
"target_project_dir": proj_b.to_string_lossy(),
|
|
586
|
+
"summary": "Spec path does not exist",
|
|
587
|
+
"details": "Description",
|
|
588
|
+
"priority": "medium",
|
|
589
|
+
"context": {
|
|
590
|
+
"spec_docs": ["/nonexistent/path/to/spec.md"]
|
|
591
|
+
},
|
|
592
|
+
"tasks": [
|
|
593
|
+
{ "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
|
|
594
|
+
]
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
599
|
+
let text = get_text(&resp);
|
|
600
|
+
assert!(
|
|
601
|
+
text.contains("does not exist"),
|
|
602
|
+
"should warn about nonexistent spec path: {text}"
|
|
603
|
+
);
|
|
604
|
+
}
|
package/tests/tool_sessions.rs
CHANGED
|
@@ -583,13 +583,13 @@ fn save_context_no_warnings_when_valid() {
|
|
|
583
583
|
{ "note": "Next: implement feature Y", "category": "suggestion" }
|
|
584
584
|
],
|
|
585
585
|
"context_pointers": [
|
|
586
|
-
{ "path": "
|
|
586
|
+
{ "path": ".handoff/config.toml", "reason": "Entry point" }
|
|
587
587
|
],
|
|
588
588
|
"decisions": [
|
|
589
589
|
{ "decision": "Use approach A", "confidence": "confirmed" }
|
|
590
590
|
],
|
|
591
591
|
"references": [
|
|
592
|
-
{ "label": "
|
|
592
|
+
{ "label": "Config", "uri": ".handoff/config.toml", "type": "doc" }
|
|
593
593
|
]
|
|
594
594
|
}),
|
|
595
595
|
);
|