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, &format!("services.{name}"), arguments))
53}
54
55/// Sends tool `name`'s arguments as an act on `subject`, under the given or
56/// a fresh operation id.
57pub(super) fn forward<R: Rail>(
58    rail: &R,
59    name: &str,
60    subject: &str,
61    arguments: &Map<String, Value>,
62) -> ToolResult {
63    let mut payload = arguments.clone();
64    let operation = match payload.remove("operation") {
65        Some(Value::String(operation))
66            if !operation.is_empty() && !operation.chars().any(char::is_control) =>
67        {
68            operation
69        }
70        Some(_) => {
71            return ToolResult::failed(
72                "operation must be a nonempty string without controls",
73                json!({"tool":name}),
74            );
75        }
76        None => {
77            let mut bytes = [0_u8; 16];
78            if std::fs::File::open("/dev/urandom")
79                .and_then(|mut file| file.read_exact(&mut bytes))
80                .is_err()
81            {
82                return ToolResult::failed(
83                    "operation_randomness_unavailable",
84                    json!({"tool":name}),
85                );
86            }
87            let digits: String = bytes
88                .iter()
89                .flat_map(|byte| {
90                    [
91                        char::from(HEX[usize::from(byte >> 4)]),
92                        char::from(HEX[usize::from(byte & 0x0f)]),
93                    ]
94                })
95                .collect();
96            format!("op-{digits}")
97        }
98    };
99    let input = json!({"operation":operation,"payload":payload});
100    match request(rail, subject, &input, true, None) {
101        Outcome::Reply(answer) => {
102            let Some(bound) = std::num::NonZeroUsize::new(rail.request_bytes()) else {
103                return ToolResult::failed("ticket_request_bound_is_zero", json!({"tool":name}));
104            };
105            match super::held::from_wire(&answer, bound, bound) {
106                Ok(Some(held)) => held,
107                Ok(None) => ToolResult::ok(answer),
108                Err(error) => ToolResult::failed(&error.to_string(), json!({"tool":name})),
109            }
110        }
111        Outcome::Sent => ToolResult::failed("the server sent no reply", json!({"tool":name})),
112        Outcome::Refused(words) => ToolResult::failed(&words, json!({"tool":name})),
113    }
114}
115
116#[cfg(test)]
117#[path = "plan_tools_tests.rs"]
118mod tests;