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
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -55,9 +55,12 @@ Add to your Claude Code MCP configuration:
|
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"handoff": {
|
|
60
|
+
"type": "stdio",
|
|
61
|
+
"command": "handoff-mcp",
|
|
62
|
+
"args": []
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
```
|
|
@@ -66,9 +69,12 @@ Add to your Claude Code MCP configuration:
|
|
|
66
69
|
|
|
67
70
|
```json
|
|
68
71
|
{
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"handoff": {
|
|
74
|
+
"type": "stdio",
|
|
75
|
+
"command": "handoff-mcp",
|
|
76
|
+
"args": []
|
|
77
|
+
}
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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>",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
use crate::storage::tasks::{find_task_dir_by_id, find_task_file, read_task, write_task};
|
|
8
|
+
|
|
9
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
11
|
+
|
|
12
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
13
|
+
let tasks_dir = handoff.join("tasks");
|
|
14
|
+
|
|
15
|
+
let task_id = arguments
|
|
16
|
+
.get("task_id")
|
|
17
|
+
.and_then(|v| v.as_str())
|
|
18
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
19
|
+
|
|
20
|
+
let criterion_index = arguments
|
|
21
|
+
.get("criterion_index")
|
|
22
|
+
.and_then(|v| v.as_u64())
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
|
|
24
|
+
as usize;
|
|
25
|
+
|
|
26
|
+
let checked = arguments
|
|
27
|
+
.get("checked")
|
|
28
|
+
.and_then(|v| v.as_bool())
|
|
29
|
+
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
30
|
+
|
|
31
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
32
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
33
|
+
|
|
34
|
+
let (mut data, status) = read_task(&task_dir)?
|
|
35
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
36
|
+
|
|
37
|
+
if criterion_index >= data.done_criteria.len() {
|
|
38
|
+
anyhow::bail!(
|
|
39
|
+
"criterion_index {criterion_index} is out of range (task has {} criteria)",
|
|
40
|
+
data.done_criteria.len()
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
data.done_criteria[criterion_index].checked = checked;
|
|
45
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
46
|
+
|
|
47
|
+
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
48
|
+
std::fs::remove_file(&old_path)?;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
write_task(&task_dir, &status, &data)?;
|
|
52
|
+
|
|
53
|
+
let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
|
|
54
|
+
let total = data.done_criteria.len();
|
|
55
|
+
|
|
56
|
+
let result = serde_json::json!({
|
|
57
|
+
"task_id": data.id,
|
|
58
|
+
"criterion_index": criterion_index,
|
|
59
|
+
"item": data.done_criteria[criterion_index].item,
|
|
60
|
+
"checked": checked,
|
|
61
|
+
"done_criteria_summary": {
|
|
62
|
+
"total": total,
|
|
63
|
+
"checked": checked_count,
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
68
|
+
}
|
|
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
|
|
|
4
4
|
use serde_json::Value;
|
|
5
5
|
|
|
6
6
|
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::expand_tilde;
|
|
8
|
+
use crate::storage::referrals::read_referral_summaries;
|
|
7
9
|
use crate::storage::sessions::read_active_sessions;
|
|
8
10
|
use crate::storage::tasks::build_task_index;
|
|
9
11
|
|
|
@@ -88,6 +90,10 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
|
88
90
|
.flat_map(|s| s.blockers.iter().cloned())
|
|
89
91
|
.collect();
|
|
90
92
|
|
|
93
|
+
let unread_referrals = read_referral_summaries(&handoff_dir.join("referrals"), Some("open"))
|
|
94
|
+
.map(|r| r.len() as u32)
|
|
95
|
+
.unwrap_or(0);
|
|
96
|
+
|
|
91
97
|
Ok(serde_json::json!({
|
|
92
98
|
"name": config.project.name,
|
|
93
99
|
"path": project_path.to_string_lossy(),
|
|
@@ -96,14 +102,6 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
|
96
102
|
"active_tasks": active_tasks,
|
|
97
103
|
"blocked_tasks": blocked_tasks,
|
|
98
104
|
"blockers": blockers,
|
|
105
|
+
"unread_referrals": unread_referrals,
|
|
99
106
|
}))
|
|
100
107
|
}
|
|
101
|
-
|
|
102
|
-
fn expand_tilde(path: &str) -> String {
|
|
103
|
-
if let Some(rest) = path.strip_prefix("~/") {
|
|
104
|
-
if let Ok(home) = std::env::var("HOME") {
|
|
105
|
-
return format!("{home}/{rest}");
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
path.to_string()
|
|
109
|
-
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_task};
|
|
7
|
+
|
|
8
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
9
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
10
|
+
|
|
11
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
12
|
+
let tasks_dir = handoff.join("tasks");
|
|
13
|
+
|
|
14
|
+
let task_id = arguments
|
|
15
|
+
.get("task_id")
|
|
16
|
+
.and_then(|v| v.as_str())
|
|
17
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
18
|
+
|
|
19
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
20
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
21
|
+
|
|
22
|
+
let (data, status) = read_task(&task_dir)?
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
24
|
+
|
|
25
|
+
let result = serde_json::json!({
|
|
26
|
+
"id": data.id,
|
|
27
|
+
"title": data.title,
|
|
28
|
+
"status": status,
|
|
29
|
+
"notes": data.notes,
|
|
30
|
+
"priority": data.priority,
|
|
31
|
+
"created_at": data.created_at,
|
|
32
|
+
"updated_at": data.updated_at,
|
|
33
|
+
"completed_at": data.completed_at,
|
|
34
|
+
"labels": data.labels,
|
|
35
|
+
"links": data.links,
|
|
36
|
+
"done_criteria": data.done_criteria,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
40
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::ensure_handoff_exists;
|
|
8
|
+
use crate::storage::git::capture_git_state;
|
|
9
|
+
use crate::storage::sessions::{
|
|
10
|
+
close_active_sessions, enforce_history_limit, write_active_session, SessionData,
|
|
11
|
+
};
|
|
12
|
+
use crate::storage::tasks::*;
|
|
13
|
+
|
|
14
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
15
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
16
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
17
|
+
let tasks_dir = handoff.join("tasks");
|
|
18
|
+
let sessions_dir = handoff.join("sessions");
|
|
19
|
+
let config_path = handoff.join("config.toml");
|
|
20
|
+
|
|
21
|
+
let source = arguments
|
|
22
|
+
.get("source")
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("'source' parameter is required"))?;
|
|
24
|
+
let source_description = source
|
|
25
|
+
.get("description")
|
|
26
|
+
.and_then(|v| v.as_str())
|
|
27
|
+
.ok_or_else(|| anyhow::anyhow!("'source.description' is required"))?;
|
|
28
|
+
let source_format = source
|
|
29
|
+
.get("format")
|
|
30
|
+
.and_then(|v| v.as_str())
|
|
31
|
+
.unwrap_or("other");
|
|
32
|
+
|
|
33
|
+
let mut tasks_created: u32 = 0;
|
|
34
|
+
let mut top_level_count: u32 = 0;
|
|
35
|
+
let mut nested_count: u32 = 0;
|
|
36
|
+
|
|
37
|
+
if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
|
|
38
|
+
for task_val in tasks {
|
|
39
|
+
let title = task_val
|
|
40
|
+
.get("title")
|
|
41
|
+
.and_then(|v| v.as_str())
|
|
42
|
+
.ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
|
|
43
|
+
|
|
44
|
+
let task_id = next_top_level_id(&tasks_dir)?;
|
|
45
|
+
let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
|
|
46
|
+
tasks_created += count;
|
|
47
|
+
top_level_count += 1;
|
|
48
|
+
nested_count += count - 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let mut session_saved = false;
|
|
53
|
+
if let Some(session) = arguments.get("session") {
|
|
54
|
+
let summary = session
|
|
55
|
+
.get("summary")
|
|
56
|
+
.and_then(|v| v.as_str())
|
|
57
|
+
.ok_or_else(|| {
|
|
58
|
+
anyhow::anyhow!("'session.summary' is required when session is provided")
|
|
59
|
+
})?;
|
|
60
|
+
|
|
61
|
+
let closed = close_active_sessions(&sessions_dir)?;
|
|
62
|
+
let git_state = capture_git_state(&project_dir)?;
|
|
63
|
+
let now = Utc::now().to_rfc3339();
|
|
64
|
+
|
|
65
|
+
let mut handoff_notes = extract_array(session, "handoff_notes");
|
|
66
|
+
|
|
67
|
+
if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
68
|
+
if !raw_notes.is_empty() {
|
|
69
|
+
handoff_notes.push(serde_json::json!({
|
|
70
|
+
"note": raw_notes,
|
|
71
|
+
"category": "context"
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let mut environment = session
|
|
77
|
+
.get("environment")
|
|
78
|
+
.cloned()
|
|
79
|
+
.unwrap_or(serde_json::json!({}));
|
|
80
|
+
if let Some(env_obj) = environment.as_object_mut() {
|
|
81
|
+
env_obj.insert(
|
|
82
|
+
"import_source".to_string(),
|
|
83
|
+
serde_json::json!({
|
|
84
|
+
"description": source_description,
|
|
85
|
+
"format": source_format
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let data = SessionData {
|
|
91
|
+
version: 2,
|
|
92
|
+
ended_at: Some(now),
|
|
93
|
+
summary: summary.to_string(),
|
|
94
|
+
branch: Some(git_state.branch),
|
|
95
|
+
commit: Some(git_state.commit),
|
|
96
|
+
dirty_files: git_state.dirty_files,
|
|
97
|
+
decisions: extract_array(session, "decisions"),
|
|
98
|
+
blockers: extract_string_array(session, "blockers"),
|
|
99
|
+
checklist: extract_array(session, "checklist"),
|
|
100
|
+
handoff_notes,
|
|
101
|
+
references: extract_array(session, "references"),
|
|
102
|
+
context_pointers: extract_array(session, "context_pointers"),
|
|
103
|
+
environment: Some(environment),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
write_active_session(&sessions_dir, &data)?;
|
|
107
|
+
|
|
108
|
+
let history_limit = if config_path.exists() {
|
|
109
|
+
read_config(&config_path)
|
|
110
|
+
.map(|c| c.settings.history_limit)
|
|
111
|
+
.unwrap_or(20)
|
|
112
|
+
} else {
|
|
113
|
+
20
|
|
114
|
+
};
|
|
115
|
+
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
116
|
+
|
|
117
|
+
let _ = closed;
|
|
118
|
+
session_saved = true;
|
|
119
|
+
} else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
120
|
+
if !raw_notes.is_empty() {
|
|
121
|
+
let closed = close_active_sessions(&sessions_dir)?;
|
|
122
|
+
let git_state = capture_git_state(&project_dir)?;
|
|
123
|
+
let now = Utc::now().to_rfc3339();
|
|
124
|
+
|
|
125
|
+
let data = SessionData {
|
|
126
|
+
version: 2,
|
|
127
|
+
ended_at: Some(now),
|
|
128
|
+
summary: format!("[import] {source_description}"),
|
|
129
|
+
branch: Some(git_state.branch),
|
|
130
|
+
commit: Some(git_state.commit),
|
|
131
|
+
dirty_files: git_state.dirty_files,
|
|
132
|
+
decisions: Vec::new(),
|
|
133
|
+
blockers: Vec::new(),
|
|
134
|
+
checklist: Vec::new(),
|
|
135
|
+
handoff_notes: vec![serde_json::json!({
|
|
136
|
+
"note": raw_notes,
|
|
137
|
+
"category": "context"
|
|
138
|
+
})],
|
|
139
|
+
references: Vec::new(),
|
|
140
|
+
context_pointers: Vec::new(),
|
|
141
|
+
environment: Some(serde_json::json!({
|
|
142
|
+
"import_source": {
|
|
143
|
+
"description": source_description,
|
|
144
|
+
"format": source_format
|
|
145
|
+
}
|
|
146
|
+
})),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
write_active_session(&sessions_dir, &data)?;
|
|
150
|
+
|
|
151
|
+
let history_limit = if config_path.exists() {
|
|
152
|
+
read_config(&config_path)
|
|
153
|
+
.map(|c| c.settings.history_limit)
|
|
154
|
+
.unwrap_or(20)
|
|
155
|
+
} else {
|
|
156
|
+
20
|
|
157
|
+
};
|
|
158
|
+
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
159
|
+
|
|
160
|
+
let _ = closed;
|
|
161
|
+
session_saved = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let mut msg = format!("Import complete:\n Source: {source_description}");
|
|
166
|
+
|
|
167
|
+
if tasks_created > 0 {
|
|
168
|
+
msg.push_str(&format!(
|
|
169
|
+
"\n Tasks created: {tasks_created} ({top_level_count} top-level, {nested_count} nested)"
|
|
170
|
+
));
|
|
171
|
+
} else {
|
|
172
|
+
msg.push_str("\n Tasks created: 0");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if session_saved {
|
|
176
|
+
msg.push_str("\n Session saved: yes");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if arguments
|
|
180
|
+
.get("raw_notes")
|
|
181
|
+
.and_then(|v| v.as_str())
|
|
182
|
+
.is_some_and(|s| !s.is_empty())
|
|
183
|
+
{
|
|
184
|
+
msg.push_str("\n Raw notes: saved as handoff_note (context)");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
Ok(msg)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fn create_task_recursive(
|
|
191
|
+
tasks_dir: &std::path::Path,
|
|
192
|
+
task_id: &str,
|
|
193
|
+
parent_dir: Option<&std::path::Path>,
|
|
194
|
+
title: &str,
|
|
195
|
+
task_val: &Value,
|
|
196
|
+
) -> Result<u32> {
|
|
197
|
+
let slug = title_to_slug(title);
|
|
198
|
+
let dir_name = format!("{task_id}-{slug}");
|
|
199
|
+
let base_dir = parent_dir.unwrap_or(tasks_dir);
|
|
200
|
+
let task_dir = base_dir.join(&dir_name);
|
|
201
|
+
|
|
202
|
+
std::fs::create_dir_all(&task_dir)
|
|
203
|
+
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
204
|
+
|
|
205
|
+
let now = Utc::now().to_rfc3339();
|
|
206
|
+
let status = task_val
|
|
207
|
+
.get("status")
|
|
208
|
+
.and_then(|v| v.as_str())
|
|
209
|
+
.unwrap_or("todo");
|
|
210
|
+
|
|
211
|
+
if !is_valid_status(status) {
|
|
212
|
+
anyhow::bail!("Invalid status: {status}");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
216
|
+
validate_priority(priority)?;
|
|
217
|
+
|
|
218
|
+
let completed_at = if is_terminal_status(status) {
|
|
219
|
+
Some(now.clone())
|
|
220
|
+
} else {
|
|
221
|
+
None
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let data = TaskData {
|
|
225
|
+
id: task_id.to_string(),
|
|
226
|
+
title: title.to_string(),
|
|
227
|
+
notes: task_val
|
|
228
|
+
.get("notes")
|
|
229
|
+
.and_then(|v| v.as_str())
|
|
230
|
+
.map(String::from),
|
|
231
|
+
priority: priority.map(String::from),
|
|
232
|
+
created_at: Some(now.clone()),
|
|
233
|
+
updated_at: Some(now),
|
|
234
|
+
completed_at,
|
|
235
|
+
labels: extract_string_array_from(task_val, "labels"),
|
|
236
|
+
links: extract_string_array_from(task_val, "links"),
|
|
237
|
+
done_criteria: extract_done_criteria(task_val),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
write_task(&task_dir, status, &data)?;
|
|
241
|
+
|
|
242
|
+
let mut count: u32 = 1;
|
|
243
|
+
|
|
244
|
+
if let Some(children) = task_val.get("children").and_then(|v| v.as_array()) {
|
|
245
|
+
for (i, child_val) in children.iter().enumerate() {
|
|
246
|
+
let child_title = child_val
|
|
247
|
+
.get("title")
|
|
248
|
+
.and_then(|v| v.as_str())
|
|
249
|
+
.ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
|
|
250
|
+
|
|
251
|
+
let child_id = format!("{task_id}.{}", i + 1);
|
|
252
|
+
count += create_task_recursive(
|
|
253
|
+
&task_dir,
|
|
254
|
+
&child_id,
|
|
255
|
+
Some(&task_dir),
|
|
256
|
+
child_title,
|
|
257
|
+
child_val,
|
|
258
|
+
)?;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
Ok(count)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn extract_array(val: &Value, key: &str) -> Vec<Value> {
|
|
266
|
+
val.get(key)
|
|
267
|
+
.and_then(|v| v.as_array())
|
|
268
|
+
.cloned()
|
|
269
|
+
.unwrap_or_default()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
|
|
273
|
+
val.get(key)
|
|
274
|
+
.and_then(|v| v.as_array())
|
|
275
|
+
.map(|arr| {
|
|
276
|
+
arr.iter()
|
|
277
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
278
|
+
.collect()
|
|
279
|
+
})
|
|
280
|
+
.unwrap_or_default()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
fn extract_string_array_from(val: &Value, key: &str) -> Vec<String> {
|
|
284
|
+
val.get(key)
|
|
285
|
+
.and_then(|v| v.as_array())
|
|
286
|
+
.map(|arr| {
|
|
287
|
+
arr.iter()
|
|
288
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
289
|
+
.collect()
|
|
290
|
+
})
|
|
291
|
+
.unwrap_or_default()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
295
|
+
val.get("done_criteria")
|
|
296
|
+
.and_then(|v| v.as_array())
|
|
297
|
+
.map(|arr| {
|
|
298
|
+
arr.iter()
|
|
299
|
+
.filter_map(|v| {
|
|
300
|
+
let item = v.get("item")?.as_str()?;
|
|
301
|
+
let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
|
|
302
|
+
Some(DoneCriterion {
|
|
303
|
+
item: item.to_string(),
|
|
304
|
+
checked,
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
.collect()
|
|
308
|
+
})
|
|
309
|
+
.unwrap_or_default()
|
|
310
|
+
}
|
|
@@ -3,6 +3,7 @@ use serde_json::Value;
|
|
|
3
3
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::config::read_config;
|
|
6
|
+
use crate::storage::referrals::read_referral_summaries;
|
|
6
7
|
use crate::storage::sessions::read_active_sessions;
|
|
7
8
|
use crate::storage::tasks::build_task_index;
|
|
8
9
|
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
@@ -90,5 +91,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
90
91
|
result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
let referrals_dir = handoff.join("referrals");
|
|
95
|
+
let open_referrals = read_referral_summaries(&referrals_dir, Some("open"))?;
|
|
96
|
+
if !open_referrals.is_empty() {
|
|
97
|
+
result["referrals"] = serde_json::to_value(&open_referrals)?;
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
serde_json::to_string_pretty(&result).context("Failed to serialize context")
|
|
94
101
|
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
pub mod check_criterion;
|
|
1
2
|
pub mod config;
|
|
2
3
|
pub mod dashboard;
|
|
4
|
+
pub mod get_task;
|
|
5
|
+
pub mod import_context;
|
|
3
6
|
pub mod init;
|
|
4
7
|
pub mod list_tasks;
|
|
5
8
|
pub mod load_context;
|
|
9
|
+
pub mod refer;
|
|
10
|
+
pub mod referrals;
|
|
6
11
|
pub mod save_context;
|
|
7
12
|
pub mod update_task;
|
|
8
13
|
|
|
@@ -31,6 +36,12 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
31
36
|
"handoff_dashboard" => dashboard::handle(arguments),
|
|
32
37
|
"handoff_get_config" => config::handle_get(arguments),
|
|
33
38
|
"handoff_update_config" => config::handle_update(arguments),
|
|
39
|
+
"handoff_get_task" => get_task::handle(arguments),
|
|
40
|
+
"handoff_check_criterion" => check_criterion::handle(arguments),
|
|
41
|
+
"handoff_import_context" => import_context::handle(arguments),
|
|
42
|
+
"handoff_refer" => refer::handle(arguments),
|
|
43
|
+
"handoff_list_referrals" => referrals::handle_list(arguments),
|
|
44
|
+
"handoff_update_referral" => referrals::handle_update(arguments),
|
|
34
45
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
35
46
|
};
|
|
36
47
|
|