handoff-mcp-server 0.7.0 → 0.7.1
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/package.json +1 -1
- package/src/mcp/handlers/load_context.rs +32 -3
- package/src/mcp/handlers/save_context.rs +12 -2
- package/tests/tool_sessions.rs +116 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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>",
|
|
@@ -5,8 +5,8 @@ use super::resolve_project_dir;
|
|
|
5
5
|
use crate::storage::config::read_config;
|
|
6
6
|
use crate::storage::referrals::read_referral_summaries;
|
|
7
7
|
use crate::storage::sessions::{
|
|
8
|
-
activate_open_sessions, activate_session_by_id,
|
|
9
|
-
resume_paused_session_by_id,
|
|
8
|
+
activate_open_sessions, activate_session_by_id, read_active_sessions, read_open_sessions,
|
|
9
|
+
read_paused_sessions, resume_paused_session_by_id,
|
|
10
10
|
};
|
|
11
11
|
use crate::storage::tasks::build_task_index;
|
|
12
12
|
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
@@ -39,11 +39,30 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
39
39
|
|
|
40
40
|
let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
|
|
41
41
|
|
|
42
|
+
let active_sessions = read_active_sessions(&sessions_dir)?;
|
|
42
43
|
let sessions = read_open_sessions(&sessions_dir)?;
|
|
43
44
|
let paused_sessions = read_paused_sessions(&sessions_dir)?;
|
|
44
45
|
|
|
45
46
|
let selected_session = if let Some(sid) = target_session_id {
|
|
46
|
-
|
|
47
|
+
let already_active = active_sessions
|
|
48
|
+
.iter()
|
|
49
|
+
.any(|s| s.id.as_deref().is_some_and(|id| id == sid));
|
|
50
|
+
if already_active {
|
|
51
|
+
active_sessions
|
|
52
|
+
.into_iter()
|
|
53
|
+
.find(|s| s.id.as_deref().is_some_and(|id| id == sid))
|
|
54
|
+
} else if !active_sessions.is_empty() {
|
|
55
|
+
let active_ids: Vec<String> = active_sessions
|
|
56
|
+
.iter()
|
|
57
|
+
.filter_map(|s| s.id.clone())
|
|
58
|
+
.collect();
|
|
59
|
+
anyhow::bail!(
|
|
60
|
+
"Cannot activate session '{sid}': another session is already active ({}).\n\
|
|
61
|
+
Use save_context with close_session_id or pause_session_id to \
|
|
62
|
+
close/pause the active session first.",
|
|
63
|
+
active_ids.join(", ")
|
|
64
|
+
);
|
|
65
|
+
} else if activate_session_by_id(&sessions_dir, sid)?.is_some() {
|
|
47
66
|
sessions
|
|
48
67
|
.into_iter()
|
|
49
68
|
.find(|s| s.id.as_deref().is_some_and(|id| id == sid))
|
|
@@ -54,6 +73,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
54
73
|
} else {
|
|
55
74
|
None
|
|
56
75
|
}
|
|
76
|
+
} else if !active_sessions.is_empty() {
|
|
77
|
+
active_sessions.into_iter().last()
|
|
78
|
+
} else if sessions.len() > 1 {
|
|
79
|
+
let open_ids: Vec<String> = sessions.iter().filter_map(|s| s.id.clone()).collect();
|
|
80
|
+
anyhow::bail!(
|
|
81
|
+
"Multiple open sessions found ({}).\n\
|
|
82
|
+
Specify session_id to choose one, or use save_context to \
|
|
83
|
+
close/pause the others first.",
|
|
84
|
+
open_ids.join(", ")
|
|
85
|
+
);
|
|
57
86
|
} else {
|
|
58
87
|
activate_open_sessions(&sessions_dir)?;
|
|
59
88
|
sessions.into_iter().last()
|
|
@@ -10,8 +10,8 @@ use crate::storage::ensure_handoff_exists;
|
|
|
10
10
|
use crate::storage::git::capture_git_state;
|
|
11
11
|
use crate::storage::sessions::{
|
|
12
12
|
close_active_sessions, close_open_sessions, close_session_by_id, enforce_history_limit,
|
|
13
|
-
generate_session_id, pause_active_sessions, pause_session_by_id,
|
|
14
|
-
SessionData,
|
|
13
|
+
generate_session_id, pause_active_sessions, pause_session_by_id, read_active_sessions,
|
|
14
|
+
write_open_session, SessionData,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -53,6 +53,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
53
53
|
let closed_open = close_open_sessions(&sessions_dir)?;
|
|
54
54
|
closed_open.len()
|
|
55
55
|
} else {
|
|
56
|
+
let active = read_active_sessions(&sessions_dir)?;
|
|
57
|
+
if active.len() > 1 {
|
|
58
|
+
let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
|
|
59
|
+
anyhow::bail!(
|
|
60
|
+
"Multiple active sessions found ({}).\n\
|
|
61
|
+
Use close_session_id or pause_session_id to specify which to \
|
|
62
|
+
close/pause before saving a new session.",
|
|
63
|
+
active_ids.join(", ")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
56
66
|
let closed_active = close_active_sessions(&sessions_dir)?;
|
|
57
67
|
let closed_open = close_open_sessions(&sessions_dir)?;
|
|
58
68
|
closed_active.len() + closed_open.len()
|
package/tests/tool_sessions.rs
CHANGED
|
@@ -1192,3 +1192,119 @@ fn full_pause_resume_lifecycle() {
|
|
|
1192
1192
|
"no more paused sessions after resume"
|
|
1193
1193
|
);
|
|
1194
1194
|
}
|
|
1195
|
+
|
|
1196
|
+
// --- active session uniqueness tests ---
|
|
1197
|
+
|
|
1198
|
+
#[test]
|
|
1199
|
+
fn load_context_rejects_activate_when_another_session_active() {
|
|
1200
|
+
let dir = setup_project();
|
|
1201
|
+
let pd = dir.path().to_string_lossy().to_string();
|
|
1202
|
+
|
|
1203
|
+
// Create session A and activate it
|
|
1204
|
+
call_tool(
|
|
1205
|
+
"handoff_save_context",
|
|
1206
|
+
json!({ "project_dir": &pd, "summary": "session A" }),
|
|
1207
|
+
);
|
|
1208
|
+
let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
|
|
1209
|
+
let text = get_text(&resp);
|
|
1210
|
+
let parsed: Value = serde_json::from_str(&text).unwrap();
|
|
1211
|
+
let sid_a = parsed["session_id"].as_str().unwrap().to_string();
|
|
1212
|
+
|
|
1213
|
+
// Create session B (without closing A — use close_session_id to close only A's open)
|
|
1214
|
+
// Actually: save_context default closes active + open, so A gets closed.
|
|
1215
|
+
// We need to simulate the scenario: write a second open session manually
|
|
1216
|
+
// while A is still active. We'll use pause to keep A alive.
|
|
1217
|
+
call_tool(
|
|
1218
|
+
"handoff_save_context",
|
|
1219
|
+
json!({
|
|
1220
|
+
"project_dir": &pd,
|
|
1221
|
+
"summary": "session B",
|
|
1222
|
+
"pause_session_id": &sid_a
|
|
1223
|
+
}),
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
// Now: A is paused, B is open. Load B (activates it).
|
|
1227
|
+
let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
|
|
1228
|
+
assert!(!is_error(&resp));
|
|
1229
|
+
let text = get_text(&resp);
|
|
1230
|
+
let parsed: Value = serde_json::from_str(&text).unwrap();
|
|
1231
|
+
let sid_b = parsed["session_id"].as_str().unwrap().to_string();
|
|
1232
|
+
|
|
1233
|
+
// Try to resume A while B is active — should fail
|
|
1234
|
+
let resp = call_tool(
|
|
1235
|
+
"handoff_load_context",
|
|
1236
|
+
json!({ "project_dir": &pd, "session_id": &sid_a }),
|
|
1237
|
+
);
|
|
1238
|
+
assert!(
|
|
1239
|
+
is_error(&resp),
|
|
1240
|
+
"should reject activating A while B is active: {}",
|
|
1241
|
+
get_text(&resp)
|
|
1242
|
+
);
|
|
1243
|
+
let text = get_text(&resp);
|
|
1244
|
+
assert!(
|
|
1245
|
+
text.contains("already active"),
|
|
1246
|
+
"error should mention active session: {text}"
|
|
1247
|
+
);
|
|
1248
|
+
assert!(
|
|
1249
|
+
text.contains(&sid_b),
|
|
1250
|
+
"error should mention the blocking session ID: {text}"
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
#[test]
|
|
1255
|
+
fn load_context_allows_reloading_already_active_session() {
|
|
1256
|
+
let dir = setup_project();
|
|
1257
|
+
let pd = dir.path().to_string_lossy().to_string();
|
|
1258
|
+
|
|
1259
|
+
call_tool(
|
|
1260
|
+
"handoff_save_context",
|
|
1261
|
+
json!({ "project_dir": &pd, "summary": "session A" }),
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
|
|
1265
|
+
let text = get_text(&resp);
|
|
1266
|
+
let parsed: Value = serde_json::from_str(&text).unwrap();
|
|
1267
|
+
let sid = parsed["session_id"].as_str().unwrap().to_string();
|
|
1268
|
+
|
|
1269
|
+
// Loading the same session again should succeed (idempotent)
|
|
1270
|
+
let resp2 = call_tool(
|
|
1271
|
+
"handoff_load_context",
|
|
1272
|
+
json!({ "project_dir": &pd, "session_id": &sid }),
|
|
1273
|
+
);
|
|
1274
|
+
assert!(
|
|
1275
|
+
!is_error(&resp2),
|
|
1276
|
+
"reloading the same active session should succeed: {}",
|
|
1277
|
+
get_text(&resp2)
|
|
1278
|
+
);
|
|
1279
|
+
let text2 = get_text(&resp2);
|
|
1280
|
+
let parsed2: Value = serde_json::from_str(&text2).unwrap();
|
|
1281
|
+
assert_eq!(parsed2["session_id"].as_str().unwrap(), sid);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
#[test]
|
|
1285
|
+
fn load_context_returns_active_session_without_open() {
|
|
1286
|
+
let dir = setup_project();
|
|
1287
|
+
let pd = dir.path().to_string_lossy().to_string();
|
|
1288
|
+
|
|
1289
|
+
call_tool(
|
|
1290
|
+
"handoff_save_context",
|
|
1291
|
+
json!({ "project_dir": &pd, "summary": "my session" }),
|
|
1292
|
+
);
|
|
1293
|
+
|
|
1294
|
+
// First load activates the open session
|
|
1295
|
+
let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
|
|
1296
|
+
let text = get_text(&resp);
|
|
1297
|
+
let parsed: Value = serde_json::from_str(&text).unwrap();
|
|
1298
|
+
let sid = parsed["session_id"].as_str().unwrap().to_string();
|
|
1299
|
+
|
|
1300
|
+
// Second load (no session_id) should return the active session
|
|
1301
|
+
let resp2 = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
|
|
1302
|
+
assert!(!is_error(&resp2), "error: {}", get_text(&resp2));
|
|
1303
|
+
let text2 = get_text(&resp2);
|
|
1304
|
+
let parsed2: Value = serde_json::from_str(&text2).unwrap();
|
|
1305
|
+
assert_eq!(
|
|
1306
|
+
parsed2["session_id"].as_str().unwrap(),
|
|
1307
|
+
sid,
|
|
1308
|
+
"should return the same active session"
|
|
1309
|
+
);
|
|
1310
|
+
}
|