Skip to main content

liminal_cli/mcp/
tools.rs

1//! Tool schemas and result shaping for the ticket.
2
3use serde_json::{Map, Value, json};
4
5use super::framing::INVALID_PARAMS;
6use super::negotiate::{Era, Refusal, shaped};
7pub use super::server::Server;
8
9/// How long a modern client may keep the tool table (`ttlMs`).
10pub(super) const TOOLS_TTL_MS: u64 = 60_000;
11
12/// The tool table as `tools/list` answers it.
13#[must_use]
14pub fn tool_table() -> Value {
15    let mut table = json!([
16        {
17            "name": "list",
18            "description": "The stations on the network's map right now: each with its id, name, device, kind, state and, for a worker, the subjects it serves and the names of its activities. A fresh read every time.",
19            "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
20        },
21        {
22            "name": "describe",
23            "description": "One subject's station and its activities with the exact input and output JSON schemas the worker registered.",
24            "inputSchema": {
25                "type": "object",
26                "required": ["subject"],
27                "properties": { "subject": { "type": "string", "description": "the subject a station serves" } },
28                "additionalProperties": false
29            }
30        },
31        {
32            "name": "call",
33            "description": "Send a JSON request on a subject and return the station's reply, or the server's refusal in its own words. With reply false the request is sent and nothing is waited for. With reply true, an unserved subject returns no_server carrying the subject with isError true and never returns the request as a reply.",
34            "inputSchema": {
35                "type": "object",
36                "required": ["subject", "input"],
37                "properties": {
38                    "subject": { "type": "string" },
39                    "input": { "type": "object", "description": "the request body the station's input schema describes" },
40                    "reply": { "type": "boolean", "default": true },
41                    "pass_for": { "type": "string", "description": "an audience, such as haematite: the ticket asks Lys for this seat's own pass for it and puts it in the input as pass" }
42                },
43                "additionalProperties": false
44            }
45        },
46        {
47            "name": "flow",
48            "description": "Fire a list of calls in order; a step's input may draw on the previous reply with {\"$prev\": \"<JSON pointer>\"} objects (\"\" is the whole reply). Stops at the first failure, naming the step.",
49            "inputSchema": {
50                "type": "object",
51                "required": ["steps"],
52                "properties": {
53                    "steps": {
54                        "type": "array",
55                        "minItems": 1,
56                        "items": {
57                            "type": "object",
58                            "required": ["subject", "input"],
59                            "properties": {
60                                "subject": { "type": "string" },
61                                "input": { "type": "object" },
62                                "reply": { "type": "boolean", "default": true },
63                                "pass_for": { "type": "string" },
64                                "keep": { "type": "boolean", "default": true, "description": "false: the reply feeds the next step's $prev but is answered only by its size" }
65                            },
66                            "additionalProperties": false
67                        }
68                    },
69                    "answer": { "type": "string", "enum": ["all", "last"], "default": "all", "description": "last: answer only the last step's reply, as reply" }
70                },
71                "additionalProperties": false
72            }
73        }
74    ]);
75    if let Some(entries) = table.as_array_mut() {
76        entries.extend(crate::network::lifecycle::tool_entries());
77        entries.extend(super::subscriptions::tool_entries());
78        entries.extend(super::listen::entries());
79        entries.extend(super::plan_tools::tool_entries());
80        entries.extend(super::template_tools::tool_entries());
81        entries.push(super::channel_send::entry());
82    }
83    table
84}
85
86/// A tool's outcome as `tools/call` answers it.
87#[derive(Clone, PartialEq, Eq)]
88pub struct ToolResult {
89    /// The result's structured content.
90    pub structured: Value,
91    /// Whether the result is a failure.
92    pub is_error: bool,
93}
94
95impl ToolResult {
96    /// A success carrying `structured`.
97    #[must_use]
98    pub const fn ok(structured: Value) -> Self {
99        Self {
100            structured,
101            is_error: false,
102        }
103    }
104
105    /// A failure in `words`, carrying `structured` beside them.
106    #[must_use]
107    pub fn failed(words: &str, structured: Value) -> Self {
108        let mut structured = structured;
109        if let Some(object) = structured.as_object_mut() {
110            object.insert("error".to_owned(), json!(words));
111        }
112        Self {
113            structured,
114            is_error: true,
115        }
116    }
117
118    pub(super) fn into_result(self, era: Era) -> Value {
119        let text = self
120            .structured
121            .get("error")
122            .and_then(Value::as_str)
123            .map_or_else(
124                || serde_json::to_string(&self.structured).unwrap_or_default(),
125                str::to_owned,
126            );
127        let text = super::held::text(&self).unwrap_or(text);
128        let mut result = Map::new();
129        result.insert(
130            "content".to_owned(),
131            json!([{ "type": "text", "text": text }]),
132        );
133        result.insert("structuredContent".to_owned(), self.structured);
134        result.insert("isError".to_owned(), json!(self.is_error));
135        shaped(era, result)
136    }
137}
138
139pub(super) fn string<'a>(
140    arguments: &'a Map<String, Value>,
141    field: &str,
142) -> Result<&'a str, Refusal> {
143    arguments
144        .get(field)
145        .and_then(Value::as_str)
146        .ok_or_else(|| Refusal {
147            code: INVALID_PARAMS,
148            message: format!("the tool call is missing {field}"),
149            data: None,
150        })
151}
152
153pub(super) fn object<'a>(
154    arguments: &'a Map<String, Value>,
155    field: &str,
156) -> Result<&'a Value, Refusal> {
157    match arguments.get(field) {
158        Some(value @ Value::Object(_)) => Ok(value),
159        _ => Err(Refusal {
160            code: INVALID_PARAMS,
161            message: format!("the tool call's {field} is missing or not an object"),
162            data: None,
163        }),
164    }
165}