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,442 @@
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
+ }