handoff-mcp-server 0.7.1 → 0.7.3

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.
@@ -1,604 +0,0 @@
1
- use serde_json::{json, Value};
2
- use tempfile::TempDir;
3
-
4
- fn send(input: &str) -> Option<Value> {
5
- let result = handoff_mcp::mcp::protocol::process_line(input)?;
6
- Some(serde_json::from_str(&result).expect("response should be valid JSON"))
7
- }
8
-
9
- fn setup_project(base: &std::path::Path, name: &str) -> std::path::PathBuf {
10
- let dir = base.join(name);
11
- std::fs::create_dir_all(&dir).unwrap();
12
-
13
- std::process::Command::new("git")
14
- .args(["init"])
15
- .current_dir(&dir)
16
- .output()
17
- .unwrap();
18
- std::process::Command::new("git")
19
- .args(["commit", "--allow-empty", "-m", "init"])
20
- .current_dir(&dir)
21
- .output()
22
- .unwrap();
23
-
24
- let req = json!({
25
- "jsonrpc": "2.0", "id": 0,
26
- "method": "tools/call",
27
- "params": {
28
- "name": "handoff_init",
29
- "arguments": {
30
- "project_dir": dir.to_string_lossy(),
31
- "project_name": name
32
- }
33
- }
34
- });
35
- send(&req.to_string()).unwrap();
36
- dir
37
- }
38
-
39
- fn call_tool(name: &str, arguments: Value) -> Value {
40
- let req = json!({
41
- "jsonrpc": "2.0", "id": 1,
42
- "method": "tools/call",
43
- "params": { "name": name, "arguments": arguments }
44
- });
45
- send(&req.to_string()).unwrap()
46
- }
47
-
48
- fn get_text(resp: &Value) -> String {
49
- resp["result"]["content"][0]["text"]
50
- .as_str()
51
- .unwrap_or("")
52
- .to_string()
53
- }
54
-
55
- fn is_error(resp: &Value) -> bool {
56
- resp["result"]["isError"].as_bool().unwrap_or(false)
57
- }
58
-
59
- fn setup_two_projects() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
60
- let base = tempfile::tempdir().expect("failed to create temp dir");
61
- let proj_a = setup_project(base.path(), "project-a");
62
- let proj_b = setup_project(base.path(), "project-b");
63
- (base, proj_a, proj_b)
64
- }
65
-
66
- #[test]
67
- fn send_referral_by_path() {
68
- let (_base, proj_a, proj_b) = setup_two_projects();
69
-
70
- let resp = call_tool(
71
- "handoff_refer",
72
- json!({
73
- "project_dir": proj_a.to_string_lossy(),
74
- "target_project_dir": proj_b.to_string_lossy(),
75
- "summary": "Please fix the bug",
76
- "referral_type": "bug",
77
- "priority": "high"
78
- }),
79
- );
80
-
81
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
82
- let text = get_text(&resp);
83
- assert!(text.contains("Referral sent"));
84
- assert!(text.contains("project-a"));
85
- assert!(text.contains("project-b"));
86
- assert!(text.contains("bug"));
87
-
88
- let referrals_dir = proj_b.join(".handoff/referrals");
89
- assert!(referrals_dir.exists());
90
- let files: Vec<_> = std::fs::read_dir(&referrals_dir)
91
- .unwrap()
92
- .filter_map(|e| e.ok())
93
- .collect();
94
- assert_eq!(files.len(), 1);
95
- assert!(files[0]
96
- .file_name()
97
- .to_string_lossy()
98
- .ends_with(".open.json"));
99
- }
100
-
101
- #[test]
102
- fn send_referral_by_name() {
103
- let (base, proj_a, _proj_b) = setup_two_projects();
104
-
105
- let resp = call_tool(
106
- "handoff_update_config",
107
- json!({
108
- "project_dir": proj_a.to_string_lossy(),
109
- "updates": {
110
- "dashboard.scan_dirs": [base.path().to_string_lossy()]
111
- }
112
- }),
113
- );
114
- assert!(!is_error(&resp), "config update error: {}", get_text(&resp));
115
-
116
- let resp = call_tool(
117
- "handoff_refer",
118
- json!({
119
- "project_dir": proj_a.to_string_lossy(),
120
- "target_project": "project-b",
121
- "summary": "Feature request via name",
122
- "referral_type": "improvement"
123
- }),
124
- );
125
-
126
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
127
- let text = get_text(&resp);
128
- assert!(text.contains("Referral sent"));
129
- assert!(text.contains("project-b"));
130
- }
131
-
132
- #[test]
133
- fn referral_appears_in_load_context() {
134
- let (_base, proj_a, proj_b) = setup_two_projects();
135
-
136
- call_tool(
137
- "handoff_refer",
138
- json!({
139
- "project_dir": proj_a.to_string_lossy(),
140
- "target_project_dir": proj_b.to_string_lossy(),
141
- "summary": "Improve error messages",
142
- "referral_type": "improvement",
143
- "priority": "medium"
144
- }),
145
- );
146
-
147
- let resp = call_tool(
148
- "handoff_load_context",
149
- json!({ "project_dir": proj_b.to_string_lossy() }),
150
- );
151
- let text = get_text(&resp);
152
- let parsed: Value = serde_json::from_str(&text).unwrap();
153
-
154
- assert!(parsed["referrals"].is_array());
155
- let referrals = parsed["referrals"].as_array().unwrap();
156
- assert_eq!(referrals.len(), 1);
157
- assert_eq!(referrals[0]["source_project"], "project-a");
158
- assert_eq!(referrals[0]["summary"], "Improve error messages");
159
- assert_eq!(referrals[0]["status"], "open");
160
- }
161
-
162
- #[test]
163
- fn list_referrals_default() {
164
- let (_base, proj_a, proj_b) = setup_two_projects();
165
-
166
- call_tool(
167
- "handoff_refer",
168
- json!({
169
- "project_dir": proj_a.to_string_lossy(),
170
- "target_project_dir": proj_b.to_string_lossy(),
171
- "summary": "Referral 1",
172
- "referral_type": "bug"
173
- }),
174
- );
175
- call_tool(
176
- "handoff_refer",
177
- json!({
178
- "project_dir": proj_a.to_string_lossy(),
179
- "target_project_dir": proj_b.to_string_lossy(),
180
- "summary": "Referral 2",
181
- "referral_type": "request"
182
- }),
183
- );
184
-
185
- let resp = call_tool(
186
- "handoff_list_referrals",
187
- json!({ "project_dir": proj_b.to_string_lossy() }),
188
- );
189
-
190
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
191
- let text = get_text(&resp);
192
- let parsed: Value = serde_json::from_str(&text).unwrap();
193
- assert_eq!(parsed["total"], 2);
194
- }
195
-
196
- #[test]
197
- fn list_referrals_with_filter() {
198
- let (_base, proj_a, proj_b) = setup_two_projects();
199
-
200
- call_tool(
201
- "handoff_refer",
202
- json!({
203
- "project_dir": proj_a.to_string_lossy(),
204
- "target_project_dir": proj_b.to_string_lossy(),
205
- "summary": "Open one",
206
- "referral_type": "bug"
207
- }),
208
- );
209
-
210
- let list_resp = call_tool(
211
- "handoff_list_referrals",
212
- json!({
213
- "project_dir": proj_b.to_string_lossy(),
214
- "status_filter": "acknowledged"
215
- }),
216
- );
217
- let text = get_text(&list_resp);
218
- let parsed: Value = serde_json::from_str(&text).unwrap();
219
- assert_eq!(parsed["total"], 0);
220
-
221
- let list_resp2 = call_tool(
222
- "handoff_list_referrals",
223
- json!({
224
- "project_dir": proj_b.to_string_lossy(),
225
- "status_filter": "open"
226
- }),
227
- );
228
- let text2 = get_text(&list_resp2);
229
- let parsed2: Value = serde_json::from_str(&text2).unwrap();
230
- assert_eq!(parsed2["total"], 1);
231
- }
232
-
233
- #[test]
234
- fn acknowledge_referral() {
235
- let (_base, proj_a, proj_b) = setup_two_projects();
236
-
237
- let send_resp = call_tool(
238
- "handoff_refer",
239
- json!({
240
- "project_dir": proj_a.to_string_lossy(),
241
- "target_project_dir": proj_b.to_string_lossy(),
242
- "summary": "Ack test",
243
- "referral_type": "request"
244
- }),
245
- );
246
- let send_text = get_text(&send_resp);
247
- let ref_id = send_text
248
- .lines()
249
- .next()
250
- .unwrap()
251
- .strip_prefix("Referral sent: ")
252
- .unwrap()
253
- .to_string();
254
-
255
- let resp = call_tool(
256
- "handoff_update_referral",
257
- json!({
258
- "project_dir": proj_b.to_string_lossy(),
259
- "referral_id": ref_id,
260
- "status": "acknowledged"
261
- }),
262
- );
263
-
264
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
265
- assert!(get_text(&resp).contains("acknowledged"));
266
-
267
- let list_resp = call_tool(
268
- "handoff_list_referrals",
269
- json!({
270
- "project_dir": proj_b.to_string_lossy(),
271
- "status_filter": "acknowledged"
272
- }),
273
- );
274
- let list_text = get_text(&list_resp);
275
- let parsed: Value = serde_json::from_str(&list_text).unwrap();
276
- assert_eq!(parsed["total"], 1);
277
- }
278
-
279
- #[test]
280
- fn resolve_referral() {
281
- let (_base, proj_a, proj_b) = setup_two_projects();
282
-
283
- let send_resp = call_tool(
284
- "handoff_refer",
285
- json!({
286
- "project_dir": proj_a.to_string_lossy(),
287
- "target_project_dir": proj_b.to_string_lossy(),
288
- "summary": "Resolve test",
289
- "referral_type": "info"
290
- }),
291
- );
292
- let send_text = get_text(&send_resp);
293
- let ref_id = send_text
294
- .lines()
295
- .next()
296
- .unwrap()
297
- .strip_prefix("Referral sent: ")
298
- .unwrap()
299
- .to_string();
300
-
301
- call_tool(
302
- "handoff_update_referral",
303
- json!({
304
- "project_dir": proj_b.to_string_lossy(),
305
- "referral_id": &ref_id,
306
- "status": "acknowledged"
307
- }),
308
- );
309
-
310
- let resp = call_tool(
311
- "handoff_update_referral",
312
- json!({
313
- "project_dir": proj_b.to_string_lossy(),
314
- "referral_id": &ref_id,
315
- "status": "resolved"
316
- }),
317
- );
318
-
319
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
320
- assert!(get_text(&resp).contains("resolved"));
321
- }
322
-
323
- #[test]
324
- fn resolved_referral_not_in_load_context() {
325
- let (_base, proj_a, proj_b) = setup_two_projects();
326
-
327
- let send_resp = call_tool(
328
- "handoff_refer",
329
- json!({
330
- "project_dir": proj_a.to_string_lossy(),
331
- "target_project_dir": proj_b.to_string_lossy(),
332
- "summary": "Will be resolved",
333
- "referral_type": "bug"
334
- }),
335
- );
336
- let send_text = get_text(&send_resp);
337
- let ref_id = send_text
338
- .lines()
339
- .next()
340
- .unwrap()
341
- .strip_prefix("Referral sent: ")
342
- .unwrap()
343
- .to_string();
344
-
345
- call_tool(
346
- "handoff_update_referral",
347
- json!({
348
- "project_dir": proj_b.to_string_lossy(),
349
- "referral_id": &ref_id,
350
- "status": "resolved"
351
- }),
352
- );
353
-
354
- let resp = call_tool(
355
- "handoff_load_context",
356
- json!({ "project_dir": proj_b.to_string_lossy() }),
357
- );
358
- let text = get_text(&resp);
359
- let parsed: Value = serde_json::from_str(&text).unwrap();
360
- assert!(
361
- parsed.get("referrals").is_none() || parsed["referrals"].as_array().unwrap().is_empty(),
362
- "resolved referrals should not appear in load_context"
363
- );
364
- }
365
-
366
- #[test]
367
- fn invalid_target_project_fails() {
368
- let (base, proj_a, _proj_b) = setup_two_projects();
369
-
370
- call_tool(
371
- "handoff_update_config",
372
- json!({
373
- "project_dir": proj_a.to_string_lossy(),
374
- "updates": {
375
- "dashboard.scan_dirs": [base.path().to_string_lossy()]
376
- }
377
- }),
378
- );
379
-
380
- let resp = call_tool(
381
- "handoff_refer",
382
- json!({
383
- "project_dir": proj_a.to_string_lossy(),
384
- "target_project": "nonexistent-project",
385
- "summary": "Should fail"
386
- }),
387
- );
388
-
389
- assert!(is_error(&resp), "should fail for nonexistent target");
390
- }
391
-
392
- #[test]
393
- fn invalid_referral_type_fails() {
394
- let (_base, proj_a, proj_b) = setup_two_projects();
395
-
396
- let resp = call_tool(
397
- "handoff_refer",
398
- json!({
399
- "project_dir": proj_a.to_string_lossy(),
400
- "target_project_dir": proj_b.to_string_lossy(),
401
- "summary": "Bad type",
402
- "referral_type": "emergency"
403
- }),
404
- );
405
-
406
- assert!(is_error(&resp), "should reject invalid referral_type");
407
- assert!(get_text(&resp).contains("Invalid referral_type"));
408
- }
409
-
410
- #[test]
411
- fn referral_validates_priority() {
412
- let (_base, proj_a, proj_b) = setup_two_projects();
413
-
414
- let resp = call_tool(
415
- "handoff_refer",
416
- json!({
417
- "project_dir": proj_a.to_string_lossy(),
418
- "target_project_dir": proj_b.to_string_lossy(),
419
- "summary": "Bad priority",
420
- "priority": "critical"
421
- }),
422
- );
423
-
424
- assert!(is_error(&resp), "should reject invalid priority");
425
- assert!(get_text(&resp).contains("Invalid priority"));
426
- }
427
-
428
- #[test]
429
- fn no_referrals_dir_is_graceful() {
430
- let base = tempfile::tempdir().unwrap();
431
- let proj = setup_project(base.path(), "lonely");
432
-
433
- let resp = call_tool(
434
- "handoff_list_referrals",
435
- json!({ "project_dir": proj.to_string_lossy() }),
436
- );
437
-
438
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
439
- let text = get_text(&resp);
440
- let parsed: Value = serde_json::from_str(&text).unwrap();
441
- assert_eq!(parsed["total"], 0);
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
- }