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