Skip to main content

liminal_cli/mcp/
plan_tools.rs

1//! The ticket forwards plan writes on its own pass, preserving refusal words.
2
3use super::tools::ToolResult;
4use crate::network::{
5    Rail,
6    call::{Outcome, request},
7};
8use serde_json::{Map, Value, json};
9use std::io::Read;
10
11const HEX: &[u8; 16] = b"0123456789abcdef";
12
13/// Two plan tools and the site's deliberate acts share the existing call wire.
14#[must_use]
15pub fn tool_entries() -> Vec<Value> {
16    vec![
17        json!({"name":"plan_set","description":"Replace the building service's plan. Only its bound builder may write. Finished steps keep their ordinals and titles.",
18            "inputSchema":{"type":"object","required":["service","steps"],"additionalProperties":false,
19            "properties":{"service":{"type":"string"},"operation":{"type":"string"},"steps":{"type":"array","minItems":1,
20                "items":{"type":"object","required":["title"],"additionalProperties":false,"properties":{"title":{"type":"string"}}}}}}}),
21        json!({"name":"plan_step","description":"Change one ordinal step. At most one step may be doing. The server returns its exact refusal words.",
22            "inputSchema":{"type":"object","required":["service","step","status"],"additionalProperties":false,
23            "properties":{"service":{"type":"string"},"operation":{"type":"string"},"step":{"type":"integer","minimum":1},
24                "status":{"enum":["todo","doing","done","blocked"]},"note":{"type":"string"}}}}),
25        json!({"name":"create_site","description":"Propose a contracts-first construction site. A person approves its draft before the network changes.",
26            "inputSchema":{"type":"object","required":["name","purpose","subjects","contracts"],"additionalProperties":false,
27            "properties":{"name":{"type":"string"},"purpose":{"type":"string"},"operation":{"type":"string"},
28                "subjects":{"type":"array","minItems":1,"items":{"type":"string"}},"contracts":{"type":"object",
29                "additionalProperties":{"type":"object","required":["input_schema_json","output_schema_json"],"additionalProperties":false,
30                    "properties":{"input_schema_json":{"type":"string"},"output_schema_json":{"type":"string"}}}}}}}),
31        json!({"name":"bind_builder","description":"Propose exactly one active builder and its site-only plan and trial serve grants.",
32            "inputSchema":{"type":"object","required":["service","agent"],"additionalProperties":false,
33                "properties":{"service":{"type":"string"},"agent":{"type":"string"},"operation":{"type":"string"}}}}),
34        json!({"name":"unbind_builder","description":"Propose revoking both recorded builder grants. Approval is required.",
35            "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
36                "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
37        json!({"name":"complete","description":"Propose completing a site with an approved template and no registered trial. Approval revokes its trial serve grant.",
38            "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
39                "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
40    ]
41}
42
43/// Handles only its own tools; it adds no local decision about a grant.
44#[must_use]
45pub fn tool<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> Option<ToolResult> {
46    if !matches!(
47        name,
48        "plan_set" | "plan_step" | "create_site" | "bind_builder" | "unbind_builder" | "complete"
49    ) {
50        return None;
51    }
52    Some(forward(rail, name, arguments))
53}
54
55pub(super) fn forward<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> ToolResult {
56    let mut payload = arguments.clone();
57    let operation = match payload.remove("operation") {
58        Some(Value::String(operation))
59            if !operation.is_empty() && !operation.chars().any(char::is_control) =>
60        {
61            operation
62        }
63        Some(_) => {
64            return ToolResult::failed(
65                "operation must be a nonempty string without controls",
66                json!({"tool":name}),
67            );
68        }
69        None => {
70            let mut bytes = [0_u8; 16];
71            if std::fs::File::open("/dev/urandom")
72                .and_then(|mut file| file.read_exact(&mut bytes))
73                .is_err()
74            {
75                return ToolResult::failed(
76                    "operation_randomness_unavailable",
77                    json!({"tool":name}),
78                );
79            }
80            let digits: String = bytes
81                .iter()
82                .flat_map(|byte| {
83                    [
84                        char::from(HEX[usize::from(byte >> 4)]),
85                        char::from(HEX[usize::from(byte & 0x0f)]),
86                    ]
87                })
88                .collect();
89            format!("op-{digits}")
90        }
91    };
92    let input = json!({"operation":operation,"payload":payload});
93    match request(rail, &format!("services.{name}"), &input, true, None) {
94        Outcome::Reply(answer) => {
95            let Some(bound) = std::num::NonZeroUsize::new(rail.request_bytes()) else {
96                return ToolResult::failed("ticket_request_bound_is_zero", json!({"tool":name}));
97            };
98            match super::held::from_wire(&answer, bound, bound) {
99                Ok(Some(held)) => held,
100                Ok(None) => ToolResult::ok(answer),
101                Err(error) => ToolResult::failed(&error.to_string(), json!({"tool":name})),
102            }
103        }
104        Outcome::Sent => ToolResult::failed("the server sent no reply", json!({"tool":name})),
105        Outcome::Refused(words) => ToolResult::failed(&words, json!({"tool":name})),
106    }
107}
108
109#[cfg(test)]
110#[path = "plan_tools_tests.rs"]
111mod tests;