Skip to main content

lean_ctx_server/tools/registered/
ctx_work_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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        // Free local reference behavior has no billing dependency. Registration,
60        // project scope, ownership, bounded execution and policy still apply.
61        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}