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                            },
65                            "additionalProperties": false
66                        }
67                    }
68                },
69                "additionalProperties": false
70            }
71        }
72    ]);
73    if let Some(entries) = table.as_array_mut() {
74        entries.extend(crate::network::lifecycle::tool_entries());
75        entries.extend(super::subscriptions::tool_entries());
76        entries.extend(super::listen::entries());
77        entries.extend(super::plan_tools::tool_entries());
78        entries.extend(super::template_tools::tool_entries());
79        entries.push(super::channel_send::entry());
80    }
81    table
82}
83
84/// A tool's outcome as `tools/call` answers it.
85#[derive(Clone, PartialEq, Eq)]
86pub struct ToolResult {
87    /// The result's structured content.
88    pub structured: Value,
89    /// Whether the result is a failure.
90    pub is_error: bool,
91}
92
93impl ToolResult {
94    /// A success carrying `structured`.
95    #[must_use]
96    pub const fn ok(structured: Value) -> Self {
97        Self {
98            structured,
99            is_error: false,
100        }
101    }
102
103    /// A failure in `words`, carrying `structured` beside them.
104    #[must_use]
105    pub fn failed(words: &str, structured: Value) -> Self {
106        let mut structured = structured;
107        if let Some(object) = structured.as_object_mut() {
108            object.insert("error".to_owned(), json!(words));
109        }
110        Self {
111            structured,
112            is_error: true,
113        }
114    }
115
116    pub(super) fn into_result(self, era: Era) -> Value {
117        let text = self
118            .structured
119            .get("error")
120            .and_then(Value::as_str)
121            .map_or_else(
122                || serde_json::to_string(&self.structured).unwrap_or_default(),
123                str::to_owned,
124            );
125        let text = super::held::text(&self).unwrap_or(text);
126        let mut result = Map::new();
127        result.insert(
128            "content".to_owned(),
129            json!([{ "type": "text", "text": text }]),
130        );
131        result.insert("structuredContent".to_owned(), self.structured);
132        result.insert("isError".to_owned(), json!(self.is_error));
133        shaped(era, result)
134    }
135}
136
137pub(super) fn string<'a>(
138    arguments: &'a Map<String, Value>,
139    field: &str,
140) -> Result<&'a str, Refusal> {
141    arguments
142        .get(field)
143        .and_then(Value::as_str)
144        .ok_or_else(|| Refusal {
145            code: INVALID_PARAMS,
146            message: format!("the tool call is missing {field}"),
147            data: None,
148        })
149}
150
151pub(super) fn object<'a>(
152    arguments: &'a Map<String, Value>,
153    field: &str,
154) -> Result<&'a Value, Refusal> {
155    match arguments.get(field) {
156        Some(value @ Value::Object(_)) => Ok(value),
157        _ => Err(Refusal {
158            code: INVALID_PARAMS,
159            message: format!("the tool call's {field} is missing or not an object"),
160            data: None,
161        }),
162    }
163}