1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
7use lean_ctx::tool_defs::tool_def;
8
9pub struct CtxWorkGraphTool;
10
11impl McpTool for CtxWorkGraphTool {
12 fn name(&self) -> &'static str {
13 "ctx_work_graph"
14 }
15
16 fn tool_def(&self) -> Tool {
17 tool_def(
18 "ctx_work_graph",
19 "Free persistent project-scoped bounded local Work Graph for multi-agent coordination. The host launches agents; start binds a host-launched child to its node (verified context capsule, leases, launch env with the child's delivery profile), receipt or cancel ends it and a stop revokes its delivery. Actions: create|delegate|claim|start|consume|complete|receipt|accept|fuse|attribution|value_report|cancel|get|observe|list. Observe returns compact state without context payloads or execution fences. Node path claims share one lease namespace with ctx_agent path leases: an agent holding src blocks starting a node that claims src/x.",
20 json!({
21 "type": "object",
22 "properties": {
23 "action": {"type":"string","enum":["create","delegate","claim","start","consume","complete","receipt","accept","fuse","attribution","value_report","cancel","get","observe","list"]},
24 "graph_id": {"type":"string","minLength":1,"maxLength":128},
25 "if_revision": {"type":"string","pattern":"^[0-9a-f]{64}$","description":"For observe: return only unchanged=true and revision if state matches. For delegate|claim|start|consume|complete|receipt|accept|cancel: the write_revision from observe; the write fails with a revision conflict and changes nothing if the graph changed since. Refused on other actions. Authorization is still enforced."},
26 "node_id": {"type":"string","minLength":1,"maxLength":128},
27 "parent_node_id": {"type":"string","minLength":1,"maxLength":128},
28 "to_agent": {"type":"string","minLength":1,"maxLength":128},
29 "capsule_ref": {"type":"string","minLength":1,"maxLength":4096},
30 "outcome_ref": {"type":"string","minLength":1,"maxLength":4096},
31 "tokens": {"type":"integer","minimum":0},
32 "cost_micros": {"type":"integer","minimum":0}
33 ,"max_concurrency": {"type":"integer","minimum":1,"maximum":16}
34 ,"receipt_id": {"type":"string","minLength":1,"maxLength":128}
35 ,"execution_fence": {"type":"string","pattern":"^fence:[0-9a-f]{64}$"}
36 ,"outcome": {"type":"string","enum":["accepted","rejected","partial"]}
37 ,"claims": {"type":"array","maxItems":64,"items":{"type":"object","properties":{"key":{"type":"string","minLength":1,"maxLength":128},"value":{"type":"string","maxLength":4096}},"required":["key","value"],"additionalProperties":false}}
38 ,"accepted_nodes": {"type":"array","minItems":1,"maxItems":256,"items":{"type":"string","minLength":1,"maxLength":128}}
39 ,"stop_reason": {"type":"string","enum":["manual_stop","stale","redundant","policy_denied","lease_lost","duplicate","low_value","execution_failed"]}
40 ,"timeout_ms": {"type":"integer","minimum":1,"maximum":1800000,"description":"Lease window for claim/start in ms (start: at most 30 min, default 5 min)."}
41 ,"path_claims": {"type":"array","maxItems":64,"items":{"type":"string","pattern":"^path:[^/\\\\](?:[^\\r\\n]*[^/\\\\])?$","maxLength":517}}
42 ,"task_ref": {"type":"string","minLength":1,"maxLength":128}
43 ,"policy_ref": {"type":"string","minLength":1,"maxLength":128}
44 ,"expected_outcome_ref": {"type":"string","minLength":1,"maxLength":128}
45 },
46 "required": ["action"],
47 "additionalProperties": false
48 }),
49 )
50 }
51
52 fn handle(
53 &self,
54 args: &Map<String, Value>,
55 ctx: &ToolContext,
56 ) -> Result<ToolOutput, ErrorData> {
57 let action = get_str(args, "action")
58 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
59 let graph_id = get_str(args, "graph_id").unwrap_or_default();
62 let agent_id = ctx
63 .agent_id
64 .as_ref()
65 .map(|agent| agent.blocking_read().clone())
66 .unwrap_or_default();
67 let changed = matches!(
68 action.as_str(),
69 "create"
70 | "delegate"
71 | "claim"
72 | "start"
73 | "consume"
74 | "complete"
75 | "receipt"
76 | "accept"
77 | "cancel"
78 );
79 let result = crate::tools::ctx_work_graph::handle(crate::tools::ctx_work_graph::Request {
80 action: &action,
81 project_root: &ctx.project_root,
82 agent_id: agent_id.as_deref().unwrap_or_default(),
83 graph_id: &graph_id,
84 if_revision: get_str(args, "if_revision").as_deref(),
85 node_id: get_str(args, "node_id").as_deref(),
86 parent_node_id: get_str(args, "parent_node_id").as_deref(),
87 to_agent: get_str(args, "to_agent").as_deref(),
88 capsule_ref: get_str(args, "capsule_ref").as_deref(),
89 outcome_ref: get_str(args, "outcome_ref").as_deref(),
90 tokens: args.get("tokens").and_then(Value::as_u64),
91 cost_micros: args.get("cost_micros").and_then(Value::as_u64),
92 max_concurrency: args
93 .get("max_concurrency")
94 .and_then(Value::as_u64)
95 .and_then(|value| usize::try_from(value).ok()),
96 receipt_id: get_str(args, "receipt_id").as_deref(),
97 execution_fence: get_str(args, "execution_fence").as_deref(),
98 outcome: get_str(args, "outcome").as_deref(),
99 claims: args.get("claims"),
100 accepted_nodes: args.get("accepted_nodes"),
101 stop_reason: get_str(args, "stop_reason").as_deref(),
102 timeout_ms: args.get("timeout_ms").and_then(Value::as_u64),
103 path_claims: args.get("path_claims"),
104 task_ref: get_str(args, "task_ref").as_deref(),
105 policy_ref: get_str(args, "policy_ref").as_deref(),
106 expected_outcome_ref: get_str(args, "expected_outcome_ref").as_deref(),
107 })
108 .map_err(|error| ErrorData::invalid_params(error, None))?;
109 Ok(ToolOutput {
110 text: result,
111 original_tokens: 0,
112 saved_tokens: 0,
113 mode: Some(action),
114 path: None,
115 changed,
116 shell_outcome: None,
117 content_blocks: None,
118 })
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn work_graph_is_free_and_keeps_registration_isolation_and_policy() {
128 let isolated = lean_ctx::core::data_dir::isolated_data_dir();
129 let args = Map::from_iter([("action".into(), Value::String("list".into()))]);
130 let context = ToolContext {
131 project_root: isolated.path().to_string_lossy().into_owned(),
132 ..ToolContext::default()
133 };
134
135 let Err(error) = CtxWorkGraphTool.handle(&args, &context) else {
136 panic!("the dispatcher must enforce agent registration");
137 };
138 assert_eq!(
139 error.message,
140 "agent must be registered first via ctx_agent"
141 );
142
143 let mut registry = lean_ctx::core::agents::AgentRegistry::new();
144 let agent = registry
145 .register(
146 "codex",
147 Some("signed graph test"),
148 &context.project_root,
149 None,
150 )
151 .unwrap();
152 registry.save().unwrap();
153 let context = ToolContext {
154 agent_id: Some(std::sync::Arc::new(tokio::sync::RwLock::new(Some(agent)))),
155 ..context
156 };
157 let create = json!({
158 "action": "create", "graph_id": "paid-graph", "node_id": "root",
159 "capsule_ref": "capsule:root", "task_ref": "task:root",
160 "policy_ref": "policy:root", "expected_outcome_ref": "outcome:root",
161 "tokens": 100, "cost_micros": 50
162 });
163 let created = CtxWorkGraphTool
164 .handle(create.as_object().unwrap(), &context)
165 .unwrap();
166 assert!(created.changed);
167 let listed = CtxWorkGraphTool.handle(&args, &context).unwrap();
168 assert!(!listed.changed);
169 let listed: Value = serde_json::from_str(&listed.text).unwrap();
170 assert_eq!(listed["graph_ids"], json!(["paid-graph"]));
171 let get = json!({"action": "get", "graph_id": "paid-graph"});
172 let before = CtxWorkGraphTool
173 .handle(get.as_object().unwrap(), &context)
174 .unwrap();
175 let observe = json!({"action": "observe", "graph_id": "paid-graph"});
176 let observed = CtxWorkGraphTool
177 .handle(observe.as_object().unwrap(), &context)
178 .unwrap();
179 assert!(!observed.changed);
180 let observation: Value = serde_json::from_str(&observed.text).unwrap();
181 assert!(observation.get("nodes").is_some());
182 assert!(!observed.text.contains("capsule:root"));
183 let conditional = json!({
184 "action": "observe", "graph_id": "paid-graph",
185 "if_revision": observation["revision"]
186 });
187 let unchanged = CtxWorkGraphTool
188 .handle(conditional.as_object().unwrap(), &context)
189 .unwrap();
190 assert!(!unchanged.changed);
191 let unchanged: Value = serde_json::from_str(&unchanged.text).unwrap();
192 assert_eq!(unchanged["unchanged"], true);
193 assert!(unchanged.get("nodes").is_none());
194 let wrong_project = ToolContext {
195 project_root: isolated
196 .path()
197 .join("other-project")
198 .to_string_lossy()
199 .into_owned(),
200 agent_id: context.agent_id.clone(),
201 ..ToolContext::default()
202 };
203 assert!(
204 CtxWorkGraphTool
205 .handle(conditional.as_object().unwrap(), &wrong_project)
206 .is_err()
207 );
208 assert_eq!(
209 before.text,
210 CtxWorkGraphTool
211 .handle(get.as_object().unwrap(), &context)
212 .unwrap()
213 .text
214 );
215 let policy_dir = std::path::Path::new(&context.project_root).join(".lean-ctx");
216 std::fs::create_dir_all(&policy_dir).unwrap();
217 std::fs::write(policy_dir.join("policy.toml"),
218 "name = \"deny-execution\"\nversion = \"1.0.0\"\ndescription = \"test\"\n[context]\ndeny_tools = [\"ctx_work_graph\"]\n").unwrap();
219 let start = json!({
220 "action": "start", "graph_id": "paid-graph", "node_id": "root"
221 });
222 let error = CtxWorkGraphTool
223 .handle(start.as_object().unwrap(), &context)
224 .err()
225 .expect("project policy must deny execution");
226 assert_eq!(error.message, "project policy denies Work Graph execution");
227 let after = CtxWorkGraphTool
228 .handle(get.as_object().unwrap(), &context)
229 .unwrap();
230 assert_eq!(
231 before.text, after.text,
232 "denial must not claim or mutate the graph"
233 );
234 std::fs::remove_file(policy_dir.join("policy.toml")).unwrap();
235 let mut free_create = create.clone();
236 free_create["graph_id"] = json!("free-graph");
237 assert!(
238 CtxWorkGraphTool
239 .handle(free_create.as_object().unwrap(), &context)
240 .expect("registered agents can create local graphs without a paid account")
241 .changed
242 );
243 assert!(
244 CtxWorkGraphTool
245 .handle(conditional.as_object().unwrap(), &context)
246 .is_ok()
247 );
248 let cancel = json!({
249 "action": "cancel", "graph_id": "paid-graph", "node_id": "root",
250 "stop_reason": "manual_stop"
251 });
252 let unregistered = ToolContext {
253 project_root: context.project_root.clone(),
254 ..ToolContext::default()
255 };
256 let error = CtxWorkGraphTool
257 .handle(cancel.as_object().unwrap(), &unregistered)
258 .err()
259 .expect("cancel still requires registration");
260 assert_eq!(
261 error.message,
262 "agent must be registered first via ctx_agent"
263 );
264 let other_project = ToolContext {
265 project_root: isolated
266 .path()
267 .join("other-project")
268 .to_string_lossy()
269 .into_owned(),
270 agent_id: context.agent_id.clone(),
271 ..ToolContext::default()
272 };
273 let error = CtxWorkGraphTool
274 .handle(cancel.as_object().unwrap(), &other_project)
275 .err()
276 .expect("cancel must preserve project isolation");
277 assert!(
278 error
279 .message
280 .contains("agent is not active in this project")
281 );
282 assert!(
283 CtxWorkGraphTool
284 .handle(cancel.as_object().unwrap(), &context)
285 .unwrap()
286 .changed
287 );
288 }
289
290 #[test]
291 fn forged_legacy_plan_files_never_bypass_agent_registration() {
292 let isolated = lean_ctx::core::data_dir::isolated_data_dir();
293 let cloud = isolated.path().join("cloud");
294 std::fs::create_dir_all(&cloud).unwrap();
295 std::fs::write(cloud.join("plan.txt"), "enterprise").unwrap();
296 std::fs::write(
297 cloud.join("plan.json"),
298 br#"{"plan":"enterprise","verified_at":9007199254740991}"#,
299 )
300 .unwrap();
301 let args = Map::from_iter([("action".into(), Value::String("list".into()))]);
302 let context = ToolContext {
303 project_root: isolated.path().to_string_lossy().into_owned(),
304 ..ToolContext::default()
305 };
306
307 let Err(error) = CtxWorkGraphTool.handle(&args, &context) else {
308 panic!("forged plan files must not bypass agent registration");
309 };
310
311 assert_eq!(
312 error.message,
313 "agent must be registered first via ctx_agent"
314 );
315 }
316}