handoff-mcp-server 0.22.0 → 0.22.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 +2 -2
- package/package.json +1 -1
- package/src/mcp/handlers/docs.rs +449 -0
- package/src/mcp/handlers/mod.rs +2 -0
- package/src/mcp/tools.rs +25 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.22.
|
|
3
|
+
version = "0.22.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -10,7 +10,7 @@ readme = "README.md"
|
|
|
10
10
|
keywords = ["mcp", "ai", "claude", "handoff", "context"]
|
|
11
11
|
categories = ["command-line-utilities", "development-tools"]
|
|
12
12
|
rust-version = "1.85"
|
|
13
|
-
exclude = ["lefthook.yml", "tmp/", "wiki/", "
|
|
13
|
+
exclude = ["lefthook.yml", "tmp/", "wiki/", ".claude/", ".vscode/"]
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
serde = { version = "1", features = ["derive"] }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.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>",
|
package/src/mcp/handlers/docs.rs
CHANGED
|
@@ -1002,6 +1002,323 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1002
1002
|
Ok(to_json(&out))
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
+
/// `handoff_doc_graph` — build a graph of every document in the project:
|
|
1006
|
+
/// `nodes[]` (one per document, with optional verification progress),
|
|
1007
|
+
/// `edges[]` (explicit parent_child/related links, plus implicit
|
|
1008
|
+
/// shared_task/shared_scope links when `include_implicit=true`), and
|
|
1009
|
+
/// `layers` (doc ids grouped by `doc_type`).
|
|
1010
|
+
pub fn handle_doc_graph(arguments: &Value) -> Result<String> {
|
|
1011
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1012
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1013
|
+
|
|
1014
|
+
let include_implicit = arguments
|
|
1015
|
+
.get("include_implicit")
|
|
1016
|
+
.and_then(|v| v.as_bool())
|
|
1017
|
+
.unwrap_or(true);
|
|
1018
|
+
let include_verification = arguments
|
|
1019
|
+
.get("include_verification")
|
|
1020
|
+
.and_then(|v| v.as_bool())
|
|
1021
|
+
.unwrap_or(false);
|
|
1022
|
+
|
|
1023
|
+
let docs = read_all_docs(&handoff)?;
|
|
1024
|
+
|
|
1025
|
+
let nodes: Vec<Value> = docs
|
|
1026
|
+
.iter()
|
|
1027
|
+
.map(|d| doc_graph_node_json(d, include_verification))
|
|
1028
|
+
.collect();
|
|
1029
|
+
|
|
1030
|
+
let mut edges = doc_graph_explicit_edges(&docs);
|
|
1031
|
+
if include_implicit {
|
|
1032
|
+
edges.extend(doc_graph_implicit_edges(&docs));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
let mut layers: std::collections::BTreeMap<String, Vec<String>> =
|
|
1036
|
+
std::collections::BTreeMap::new();
|
|
1037
|
+
for d in &docs {
|
|
1038
|
+
layers
|
|
1039
|
+
.entry(d.doc_type.clone())
|
|
1040
|
+
.or_default()
|
|
1041
|
+
.push(d.id.clone());
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
Ok(to_json(&json!({
|
|
1045
|
+
"nodes": nodes,
|
|
1046
|
+
"edges": edges,
|
|
1047
|
+
"layers": layers,
|
|
1048
|
+
})))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/// Builds one `handoff_doc_graph` node: id/slug/title/doc_type/tags/task_ids
|
|
1052
|
+
/// /section_count/updated_at, plus `verification_progress` when requested
|
|
1053
|
+
/// (and the document has a verification matrix).
|
|
1054
|
+
fn doc_graph_node_json(doc: &DocMetadata, include_verification: bool) -> Value {
|
|
1055
|
+
let mut node = json!({
|
|
1056
|
+
"id": doc.id,
|
|
1057
|
+
"slug": doc.slug,
|
|
1058
|
+
"title": doc.title,
|
|
1059
|
+
"doc_type": doc.doc_type,
|
|
1060
|
+
"tags": doc.tags,
|
|
1061
|
+
"task_ids": doc.task_ids,
|
|
1062
|
+
"section_count": doc.sections.len(),
|
|
1063
|
+
"updated_at": doc.updated_at,
|
|
1064
|
+
});
|
|
1065
|
+
if include_verification {
|
|
1066
|
+
if let Some(v) = &doc.verification {
|
|
1067
|
+
let total = v.items.len();
|
|
1068
|
+
let verified = v.items.iter().filter(|i| i.status == "verified").count();
|
|
1069
|
+
node["verification_progress"] = json!({ "total": total, "verified": verified });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
node
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/// Explicit edges: `parent_id` (`type="parent_child"`, `direction="down"`,
|
|
1076
|
+
/// from=parent to=child) and `related[]` (`type=<rel>`,
|
|
1077
|
+
/// `direction="forward"`, from=this doc to=related target). Related entries
|
|
1078
|
+
/// pointing at an id not present in `docs` are still emitted — the graph
|
|
1079
|
+
/// consumer is expected to render dangling links, not silently drop them.
|
|
1080
|
+
fn doc_graph_explicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1081
|
+
let mut edges = Vec::new();
|
|
1082
|
+
for d in docs {
|
|
1083
|
+
if let Some(parent_id) = &d.parent_id {
|
|
1084
|
+
edges.push(json!({
|
|
1085
|
+
"from": parent_id,
|
|
1086
|
+
"to": d.id,
|
|
1087
|
+
"type": "parent_child",
|
|
1088
|
+
"direction": "down",
|
|
1089
|
+
}));
|
|
1090
|
+
}
|
|
1091
|
+
for r in &d.related {
|
|
1092
|
+
edges.push(json!({
|
|
1093
|
+
"from": d.id,
|
|
1094
|
+
"to": r.id,
|
|
1095
|
+
"type": r.rel,
|
|
1096
|
+
"direction": "forward",
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
edges
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/// Implicit edges: `shared_task` (two documents sharing at least one
|
|
1104
|
+
/// `task_ids` entry — `task_ids` on the edge lists every id shared, not just
|
|
1105
|
+
/// the first) and `shared_scope` (two documents sharing at least one
|
|
1106
|
+
/// `scope_paths` entry). Both are unordered/undirected pairs, emitted once
|
|
1107
|
+
/// per pair (i<j) to avoid duplicating the same relationship in both
|
|
1108
|
+
/// directions.
|
|
1109
|
+
fn doc_graph_implicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1110
|
+
let mut edges = Vec::new();
|
|
1111
|
+
for i in 0..docs.len() {
|
|
1112
|
+
for j in (i + 1)..docs.len() {
|
|
1113
|
+
let a = &docs[i];
|
|
1114
|
+
let b = &docs[j];
|
|
1115
|
+
|
|
1116
|
+
let shared_tasks: Vec<String> = a
|
|
1117
|
+
.task_ids
|
|
1118
|
+
.iter()
|
|
1119
|
+
.filter(|t| b.task_ids.contains(t))
|
|
1120
|
+
.cloned()
|
|
1121
|
+
.collect();
|
|
1122
|
+
if !shared_tasks.is_empty() {
|
|
1123
|
+
edges.push(json!({
|
|
1124
|
+
"from": a.id,
|
|
1125
|
+
"to": b.id,
|
|
1126
|
+
"type": "shared_task",
|
|
1127
|
+
"task_ids": shared_tasks,
|
|
1128
|
+
}));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let shares_scope = a.scope_paths.iter().any(|p| b.scope_paths.contains(p));
|
|
1132
|
+
if shares_scope {
|
|
1133
|
+
edges.push(json!({
|
|
1134
|
+
"from": a.id,
|
|
1135
|
+
"to": b.id,
|
|
1136
|
+
"type": "shared_scope",
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
edges
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/// One entry in a `handoff_doc_trace` `chain[]`/`branches[].docs[]`:
|
|
1145
|
+
/// `{id, title, doc_type, rel}`. `rel` describes how this doc relates to the
|
|
1146
|
+
/// previous entry in the chain ("parent", "child", or the `related[].rel`
|
|
1147
|
+
/// value for a related-doc detour); `None` for the trace's starting doc.
|
|
1148
|
+
fn doc_trace_item_json(doc: &DocMetadata, rel: Option<&str>) -> Value {
|
|
1149
|
+
json!({
|
|
1150
|
+
"id": doc.id,
|
|
1151
|
+
"title": doc.title,
|
|
1152
|
+
"doc_type": doc.doc_type,
|
|
1153
|
+
"rel": rel,
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// Walks the child->parent chain starting at `doc` (exclusive — `doc` itself
|
|
1158
|
+
/// is not included), ordered from the immediate parent up to the root.
|
|
1159
|
+
/// `visited` prevents infinite loops on a cyclic `parent_id` graph; a doc
|
|
1160
|
+
/// already visited (including `doc` itself) stops the walk rather than
|
|
1161
|
+
/// erroring.
|
|
1162
|
+
fn doc_trace_walk_up(
|
|
1163
|
+
handoff: &Path,
|
|
1164
|
+
doc: &DocMetadata,
|
|
1165
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1166
|
+
) -> Result<Vec<Value>> {
|
|
1167
|
+
let mut out = Vec::new();
|
|
1168
|
+
let mut current = doc.clone();
|
|
1169
|
+
while let Some(parent_id) = current.parent_id.clone() {
|
|
1170
|
+
if visited.contains(&parent_id) {
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
let Some(parent) = find_doc_by_id(handoff, &parent_id)? else {
|
|
1174
|
+
break;
|
|
1175
|
+
};
|
|
1176
|
+
visited.insert(parent.id.clone());
|
|
1177
|
+
out.push(doc_trace_item_json(&parent, Some("parent")));
|
|
1178
|
+
current = parent;
|
|
1179
|
+
}
|
|
1180
|
+
out.reverse();
|
|
1181
|
+
Ok(out)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/// Recursively walks parent->children (DFS) starting at `doc` (exclusive).
|
|
1185
|
+
/// Returns the primary descendant chain (first child at each level) plus any
|
|
1186
|
+
/// `branches` recorded for multi-child forks. `visited` prevents infinite
|
|
1187
|
+
/// loops on a cyclic `children` graph.
|
|
1188
|
+
fn doc_trace_walk_down(
|
|
1189
|
+
handoff: &Path,
|
|
1190
|
+
doc: &DocMetadata,
|
|
1191
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1192
|
+
branches: &mut Vec<Value>,
|
|
1193
|
+
) -> Result<Vec<Value>> {
|
|
1194
|
+
let mut children = Vec::new();
|
|
1195
|
+
for child_id in &doc.children {
|
|
1196
|
+
if visited.contains(child_id) {
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if let Some(child) = find_doc_by_id(handoff, child_id)? {
|
|
1200
|
+
children.push(child);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if children.is_empty() {
|
|
1205
|
+
return Ok(Vec::new());
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Fork detection: more than one live (non-visited, resolvable) child at
|
|
1209
|
+
// this level. Every child's own sub-chain is recorded under `branches`;
|
|
1210
|
+
// the first child's sub-chain also becomes the primary continuation of
|
|
1211
|
+
// the returned chain, so a single-child level still reads as a plain
|
|
1212
|
+
// linear chain.
|
|
1213
|
+
let is_fork = children.len() > 1;
|
|
1214
|
+
let mut primary_chain = Vec::new();
|
|
1215
|
+
|
|
1216
|
+
for (idx, child) in children.iter().enumerate() {
|
|
1217
|
+
if visited.contains(&child.id) {
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
visited.insert(child.id.clone());
|
|
1221
|
+
let mut sub_chain = vec![doc_trace_item_json(child, Some("child"))];
|
|
1222
|
+
sub_chain.extend(doc_trace_walk_down(handoff, child, visited, branches)?);
|
|
1223
|
+
|
|
1224
|
+
if is_fork {
|
|
1225
|
+
branches.push(json!({
|
|
1226
|
+
"fork_from": doc.id,
|
|
1227
|
+
"docs": sub_chain,
|
|
1228
|
+
}));
|
|
1229
|
+
}
|
|
1230
|
+
if idx == 0 {
|
|
1231
|
+
primary_chain = sub_chain;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
Ok(primary_chain)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/// Appends `related` (implements/references/etc.) detours for every document
|
|
1239
|
+
/// already present in `chain` (by id), skipping any related id already
|
|
1240
|
+
/// visited. Related docs are appended once, immediately, as a flat list — a
|
|
1241
|
+
/// "detour" from the main chain rather than a further recursive expansion.
|
|
1242
|
+
fn doc_trace_related_detours(
|
|
1243
|
+
handoff: &Path,
|
|
1244
|
+
chain_doc_ids: &[String],
|
|
1245
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1246
|
+
) -> Result<Vec<Value>> {
|
|
1247
|
+
let mut out = Vec::new();
|
|
1248
|
+
for doc_id in chain_doc_ids {
|
|
1249
|
+
let Some(doc) = find_doc_by_id(handoff, doc_id)? else {
|
|
1250
|
+
continue;
|
|
1251
|
+
};
|
|
1252
|
+
for r in &doc.related {
|
|
1253
|
+
if visited.contains(&r.id) {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
1257
|
+
continue;
|
|
1258
|
+
};
|
|
1259
|
+
visited.insert(target.id.clone());
|
|
1260
|
+
out.push(doc_trace_item_json(&target, Some(&r.rel)));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
Ok(out)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/// `handoff_doc_trace` — trace a document's family-tree lineage: `up` (walk
|
|
1267
|
+
/// child->parent), `down` (walk parent->children, DFS), or `both` (merge the
|
|
1268
|
+
/// up chain + the target + the down chain). `related` docs encountered along
|
|
1269
|
+
/// the primary chain are appended as detour entries. Multi-child forks in the
|
|
1270
|
+
/// `down` direction are additionally reported in `branches[]`.
|
|
1271
|
+
pub fn handle_doc_trace(arguments: &Value) -> Result<String> {
|
|
1272
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1273
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1274
|
+
|
|
1275
|
+
let doc_id = arguments
|
|
1276
|
+
.get("doc_id")
|
|
1277
|
+
.and_then(|v| v.as_str())
|
|
1278
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
1279
|
+
let direction = arguments
|
|
1280
|
+
.get("direction")
|
|
1281
|
+
.and_then(|v| v.as_str())
|
|
1282
|
+
.unwrap_or("both");
|
|
1283
|
+
|
|
1284
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
1285
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
1286
|
+
|
|
1287
|
+
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
1288
|
+
visited.insert(doc.id.clone());
|
|
1289
|
+
|
|
1290
|
+
let mut branches: Vec<Value> = Vec::new();
|
|
1291
|
+
let mut chain: Vec<Value> = Vec::new();
|
|
1292
|
+
|
|
1293
|
+
if direction == "up" || direction == "both" {
|
|
1294
|
+
chain.extend(doc_trace_walk_up(&handoff, &doc, &mut visited)?);
|
|
1295
|
+
}
|
|
1296
|
+
chain.push(doc_trace_item_json(&doc, None));
|
|
1297
|
+
if direction == "down" || direction == "both" {
|
|
1298
|
+
chain.extend(doc_trace_walk_down(
|
|
1299
|
+
&handoff,
|
|
1300
|
+
&doc,
|
|
1301
|
+
&mut visited,
|
|
1302
|
+
&mut branches,
|
|
1303
|
+
)?);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
let chain_doc_ids: Vec<String> = chain
|
|
1307
|
+
.iter()
|
|
1308
|
+
.filter_map(|v| v["id"].as_str().map(str::to_string))
|
|
1309
|
+
.collect();
|
|
1310
|
+
chain.extend(doc_trace_related_detours(
|
|
1311
|
+
&handoff,
|
|
1312
|
+
&chain_doc_ids,
|
|
1313
|
+
&mut visited,
|
|
1314
|
+
)?);
|
|
1315
|
+
|
|
1316
|
+
Ok(to_json(&json!({
|
|
1317
|
+
"chain": chain,
|
|
1318
|
+
"branches": branches,
|
|
1319
|
+
})))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1005
1322
|
fn doc_metadata_json(doc: &DocMetadata) -> Value {
|
|
1006
1323
|
json!({
|
|
1007
1324
|
"id": doc.id,
|
|
@@ -1041,3 +1358,135 @@ fn string_array_value(v: &Value) -> Vec<String> {
|
|
|
1041
1358
|
fn to_json(v: &Value) -> String {
|
|
1042
1359
|
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
1043
1360
|
}
|
|
1361
|
+
|
|
1362
|
+
#[cfg(test)]
|
|
1363
|
+
mod graph_tests {
|
|
1364
|
+
use super::*;
|
|
1365
|
+
|
|
1366
|
+
fn doc(id: &str, slug: &str, doc_type: &str) -> DocMetadata {
|
|
1367
|
+
DocMetadata::new(
|
|
1368
|
+
id.to_string(),
|
|
1369
|
+
slug.to_string(),
|
|
1370
|
+
format!("Title {id}"),
|
|
1371
|
+
doc_type.to_string(),
|
|
1372
|
+
"2026-07-12T00:00:00Z".to_string(),
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
#[test]
|
|
1377
|
+
fn explicit_edges_include_parent_child_and_related() {
|
|
1378
|
+
let mut parent = doc("doc-1", "parent", "spec");
|
|
1379
|
+
let mut child = doc("doc-2", "child", "design");
|
|
1380
|
+
child.parent_id = Some("doc-1".to_string());
|
|
1381
|
+
parent.children = vec!["doc-2".to_string()];
|
|
1382
|
+
child.related.push(DocRelation {
|
|
1383
|
+
id: "doc-3".to_string(),
|
|
1384
|
+
rel: "implements".to_string(),
|
|
1385
|
+
});
|
|
1386
|
+
let other = doc("doc-3", "other", "note");
|
|
1387
|
+
|
|
1388
|
+
let docs = vec![parent, child, other];
|
|
1389
|
+
let edges = doc_graph_explicit_edges(&docs);
|
|
1390
|
+
|
|
1391
|
+
assert!(edges.iter().any(|e| e["type"] == "parent_child"
|
|
1392
|
+
&& e["from"] == "doc-1"
|
|
1393
|
+
&& e["to"] == "doc-2"
|
|
1394
|
+
&& e["direction"] == "down"));
|
|
1395
|
+
assert!(edges.iter().any(|e| e["type"] == "implements"
|
|
1396
|
+
&& e["from"] == "doc-2"
|
|
1397
|
+
&& e["to"] == "doc-3"
|
|
1398
|
+
&& e["direction"] == "forward"));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
#[test]
|
|
1402
|
+
fn implicit_edges_detect_shared_task_ids() {
|
|
1403
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1404
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1405
|
+
a.task_ids = vec!["t-1".to_string(), "t-2".to_string()];
|
|
1406
|
+
b.task_ids = vec!["t-2".to_string(), "t-3".to_string()];
|
|
1407
|
+
let docs = vec![a, b];
|
|
1408
|
+
|
|
1409
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1410
|
+
let shared_task_edge = edges
|
|
1411
|
+
.iter()
|
|
1412
|
+
.find(|e| e["type"] == "shared_task")
|
|
1413
|
+
.expect("shared_task edge must be generated");
|
|
1414
|
+
assert_eq!(shared_task_edge["from"], "doc-1");
|
|
1415
|
+
assert_eq!(shared_task_edge["to"], "doc-2");
|
|
1416
|
+
assert_eq!(shared_task_edge["task_ids"], json!(["t-2"]));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
#[test]
|
|
1420
|
+
fn implicit_edges_detect_shared_scope_paths() {
|
|
1421
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1422
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1423
|
+
a.scope_paths = vec!["src/mcp/".to_string()];
|
|
1424
|
+
b.scope_paths = vec!["src/mcp/".to_string(), "src/storage/".to_string()];
|
|
1425
|
+
let docs = vec![a, b];
|
|
1426
|
+
|
|
1427
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1428
|
+
assert!(edges
|
|
1429
|
+
.iter()
|
|
1430
|
+
.any(|e| e["type"] == "shared_scope" && e["from"] == "doc-1" && e["to"] == "doc-2"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#[test]
|
|
1434
|
+
fn implicit_edges_absent_when_nothing_shared() {
|
|
1435
|
+
let a = doc("doc-1", "a", "spec");
|
|
1436
|
+
let b = doc("doc-2", "b", "spec");
|
|
1437
|
+
let docs = vec![a, b];
|
|
1438
|
+
|
|
1439
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1440
|
+
assert!(edges.is_empty());
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
#[test]
|
|
1444
|
+
fn graph_node_json_includes_verification_progress_when_requested() {
|
|
1445
|
+
let mut d = doc("doc-1", "a", "spec");
|
|
1446
|
+
d.verification = Some(Verification {
|
|
1447
|
+
status: "in_review".to_string(),
|
|
1448
|
+
created_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1449
|
+
updated_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1450
|
+
items: vec![
|
|
1451
|
+
VerificationItem {
|
|
1452
|
+
fragment_seq: 0,
|
|
1453
|
+
heading: String::new(),
|
|
1454
|
+
status: "verified".to_string(),
|
|
1455
|
+
impl_refs: Vec::new(),
|
|
1456
|
+
test_refs: Vec::new(),
|
|
1457
|
+
reviewer: None,
|
|
1458
|
+
verified_at: None,
|
|
1459
|
+
notes: String::new(),
|
|
1460
|
+
content_hash_at_verify: None,
|
|
1461
|
+
},
|
|
1462
|
+
VerificationItem {
|
|
1463
|
+
fragment_seq: 1,
|
|
1464
|
+
heading: "H".to_string(),
|
|
1465
|
+
status: "pending".to_string(),
|
|
1466
|
+
impl_refs: Vec::new(),
|
|
1467
|
+
test_refs: Vec::new(),
|
|
1468
|
+
reviewer: None,
|
|
1469
|
+
verified_at: None,
|
|
1470
|
+
notes: String::new(),
|
|
1471
|
+
content_hash_at_verify: None,
|
|
1472
|
+
},
|
|
1473
|
+
],
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
let with_verification = doc_graph_node_json(&d, true);
|
|
1477
|
+
assert_eq!(
|
|
1478
|
+
with_verification["verification_progress"],
|
|
1479
|
+
json!({ "total": 2, "verified": 1 })
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
let without_verification = doc_graph_node_json(&d, false);
|
|
1483
|
+
assert!(without_verification.get("verification_progress").is_none());
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
#[test]
|
|
1487
|
+
fn graph_node_json_omits_verification_progress_when_no_matrix() {
|
|
1488
|
+
let d = doc("doc-1", "a", "spec");
|
|
1489
|
+
let node = doc_graph_node_json(&d, true);
|
|
1490
|
+
assert!(node.get("verification_progress").is_none());
|
|
1491
|
+
}
|
|
1492
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -103,6 +103,8 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
103
103
|
"handoff_doc_delete" => docs::handle_doc_delete(arguments),
|
|
104
104
|
"handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
|
|
105
105
|
"handoff_doc_tree" => docs::handle_doc_tree(arguments),
|
|
106
|
+
"handoff_doc_graph" => docs::handle_doc_graph(arguments),
|
|
107
|
+
"handoff_doc_trace" => docs::handle_doc_trace(arguments),
|
|
106
108
|
"handoff_doc_verify" => docs::handle_doc_verify(arguments),
|
|
107
109
|
"handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
|
|
108
110
|
"handoff_doc_query" => docs_query::handle_doc_query(arguments),
|
package/src/mcp/tools.rs
CHANGED
|
@@ -1358,6 +1358,31 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1358
1358
|
"required": ["doc_id"]
|
|
1359
1359
|
}),
|
|
1360
1360
|
},
|
|
1361
|
+
ToolDefinition {
|
|
1362
|
+
name: "handoff_doc_graph".to_string(),
|
|
1363
|
+
description: "Build a graph of every document in the project: nodes (one per document, with id/slug/title/doc_type/tags/task_ids/section_count/updated_at, plus verification_progress {total,verified} when include_verification=true and a matrix exists), edges (explicit parent_id -> type='parent_child'/direction='down', explicit related[] -> type=<rel>/direction='forward', and — when include_implicit=true — implicit shared_task edges for documents sharing task_ids and shared_scope edges for documents sharing scope_paths), and layers (doc ids grouped by doc_type). Intended for graph-visualization UIs. Returns a JSON string {nodes:[…],edges:[…],layers:{…}}.".to_string(),
|
|
1364
|
+
input_schema: json!({
|
|
1365
|
+
"type": "object",
|
|
1366
|
+
"properties": {
|
|
1367
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1368
|
+
"include_implicit": { "type": "boolean", "description": "Also emit shared_task/shared_scope implicit edges.", "default": true },
|
|
1369
|
+
"include_verification": { "type": "boolean", "description": "Attach verification_progress {total,verified} to each node that has a verification matrix.", "default": false }
|
|
1370
|
+
}
|
|
1371
|
+
}),
|
|
1372
|
+
},
|
|
1373
|
+
ToolDefinition {
|
|
1374
|
+
name: "handoff_doc_trace".to_string(),
|
|
1375
|
+
description: "Trace a document's family-tree lineage from doc_id (id or slug): direction='up' walks the child->parent chain to the root; 'down' walks parent->children (DFS); 'both' (default) merges the up chain, the target doc, and the down chain into one ordered chain (root to leaf). related (implements/references/etc.) documents encountered along the chain are appended as detour entries. Multi-child forks encountered in the down direction are additionally reported in branches[] (one entry per fork, {fork_from,docs:[…]}). Cycle-safe: a visited set skips any document already seen in the traversal. Returns a JSON string {chain:[{id,title,doc_type,rel}…],branches:[{fork_from,docs:[…]}…]}.".to_string(),
|
|
1376
|
+
input_schema: json!({
|
|
1377
|
+
"type": "object",
|
|
1378
|
+
"properties": {
|
|
1379
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1380
|
+
"doc_id": { "type": "string", "description": "Document id or slug to trace from." },
|
|
1381
|
+
"direction": { "type": "string", "description": "Traversal direction.", "enum": ["up", "down", "both"], "default": "both" }
|
|
1382
|
+
},
|
|
1383
|
+
"required": ["doc_id"]
|
|
1384
|
+
}),
|
|
1385
|
+
},
|
|
1361
1386
|
ToolDefinition {
|
|
1362
1387
|
name: "handoff_doc_query".to_string(),
|
|
1363
1388
|
description: "Inject document sections relevant to the current prompt/file/task (hook-driven context injection, mirrors memory_query at section granularity). Ranks by BM25 relevance + scope_paths match + task_id affinity, then stages each result as 'full' (whole section body, when its token estimate is within the inline threshold) or 'outline' (heading + sibling table of contents only, for larger sections — fetch the body via doc_get(format='section')). With session_id, already-injected sections (same content_hash) are skipped this session; mark_injected (default true) records survivors. suppress_doc_ids excludes given documents from this call's results; combined with suppress_until_changed=true (requires session_id), the suppression is recorded in the session's injected sidecar and persists across future calls until that document's content_hash changes. Returns a JSON string {documents:[…],injected_count}.".to_string(),
|