Skip to main content

liminal_cli/mcp/
server.rs

1//! The negotiated tools and the session that owns their subscriptions.
2
3use super::framing::{self, INVALID_PARAMS, METHOD_NOT_FOUND};
4use super::negotiate::{Era, Refusal, Session, shaped};
5use super::tools::{TOOLS_TTL_MS, ToolResult, object, string, tool_table};
6use crate::network::{Rail, call, directory, flow};
7use serde_json::{Map, Value, json};
8
9/// The ticket's server: one rail, one session and its service subscriptions.
10pub struct Server<R> {
11    rail: R,
12    session: Session,
13    pub(super) frame_bytes: usize,
14    /// The inline answer bound and the seat's own hand-over directory (SRV-052 R2).
15    pub(super) answer_bytes: usize,
16    pub(super) answer_dir: Option<std::path::PathBuf>,
17    pub(super) subscriptions: std::sync::Arc<super::subscriptions::Subscriptions>,
18    pub(super) warning_only: std::sync::Arc<std::sync::atomic::AtomicBool>,
19    pub(super) listens: std::sync::Arc<super::listen::Listens>,
20    pub(super) listen_config: super::listen_config::ListenConfig,
21    pub(super) channel_source: Option<super::channel_source::Source>,
22    pub(super) initial_listens: Vec<String>,
23    /// The agent's own inbox when no `[listen]` entry names it: a station
24    /// the exchange does not hold leaves the ticket's tools serving.
25    pub(super) own_inbox: Option<String>,
26}
27
28/// `$HOME/.liminal/answers/<seat name>-<pid>`: the seat's own directory, one
29/// per seat process, so a seat removes only the files it handed over; `None`
30/// without a `HOME`. Never the system temp directory, which production code
31/// does not reach (the workspace's temp-store pin).
32fn answer_dir(name: Option<&str>) -> Option<std::path::PathBuf> {
33    let home = std::env::var_os("HOME").filter(|home| !home.is_empty())?;
34    Some(
35        std::path::PathBuf::from(home)
36            .join(".liminal")
37            .join("answers")
38            .join(format!("{}-{}", name.unwrap_or("seat"), std::process::id())),
39    )
40}
41
42impl<R> std::fmt::Debug for Server<R> {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Server")
45            .field("session", &self.session)
46            .field("frame_bytes", &self.frame_bytes)
47            .field("listens", &self.listens)
48            .field("channel_source", &self.channel_source)
49            .finish_non_exhaustive()
50    }
51}
52
53impl<R: Rail> Server<R> {
54    /// A server over `rail`, reading lines of at most `frame_bytes`.
55    #[must_use]
56    pub fn new(rail: R, frame_bytes: usize) -> Self {
57        Self {
58            rail,
59            session: Session::new(),
60            frame_bytes,
61            answer_bytes: 16_777_216,
62            answer_dir: answer_dir(None),
63            subscriptions: std::sync::Arc::default(),
64            warning_only: std::sync::Arc::default(),
65            listens: std::sync::Arc::default(),
66            listen_config: super::listen_config::ListenConfig::default(),
67            channel_source: None,
68            initial_listens: Vec::new(),
69            own_inbox: None,
70        }
71    }
72
73    /// A ticket on the validated seat, with initial listens opened by `serve`.
74    /// No socket or pass request occurs in this constructor.
75    ///
76    /// # Errors
77    /// Returns a named pass-source or listen-configuration refusal.
78    pub fn configured(rail: R, config: &crate::config::Config) -> Result<Self, String> {
79        config.listen.validate(config.agent.as_deref())?;
80        let source = super::channel_source::Source::new(config)?;
81        let mut server = Self::new(rail, config.limits.frame_bytes);
82        server.answer_bytes = config.limits.answer_bytes;
83        server.answer_dir = answer_dir(config.agent.as_deref());
84        server.listen_config = config.listen.clone();
85        server.initial_listens = config.listen.initial_subjects(config.agent.as_deref());
86        server.own_inbox = config
87            .agent
88            .as_deref()
89            .map(|agent| format!("agents.{agent}.inbox"))
90            .filter(|inbox| !config.listen.subjects.contains(inbox));
91        server.channel_source = Some(source);
92        Ok(server)
93    }
94
95    /// Active channel subjects and all ticket channel threads, respectively.
96    /// Each subject owns two streams, a tap reader and a join worker.
97    ///
98    /// # Errors
99    /// Returns a poisoned listen-state lock.
100    pub fn listen_counts(&self) -> Result<(usize, usize), String> {
101        Ok((
102            self.listens.count()?,
103            self.listens
104                .receivers
105                .load(std::sync::atomic::Ordering::SeqCst),
106        ))
107    }
108
109    pub(super) fn start_listens(&self) -> std::io::Result<()> {
110        let Some(source) = &self.channel_source else {
111            return Ok(());
112        };
113        for subject in &self.initial_listens {
114            let answer = self.listens.listen(source, &self.listen_config, subject);
115            if answer.is_error {
116                let words = answer.structured["error"]
117                    .as_str()
118                    .ok_or_else(|| std::io::Error::other("the listen refusal has no words"))?;
119                // The implicit inbox is refused in the server's words and the
120                // tools keep serving; a configured subject's refusal is fatal.
121                if self.own_inbox.as_deref() == Some(subject.as_str()) {
122                    eprintln!("liminal mcp: {subject} is not listened: {words}");
123                    continue;
124                }
125                let closed = self.listens.close_all();
126                return Err(std::io::Error::other(match closed {
127                    Ok(()) => words.to_owned(),
128                    Err(close) => format!("{words}; {close}"),
129                }));
130            }
131        }
132        Ok(())
133    }
134
135    /// The rail behind the tools.
136    #[must_use]
137    pub const fn rail(&self) -> &R {
138        &self.rail
139    }
140
141    /// One request's answer.
142    pub fn answer(&mut self, id: &Value, method: &str, params: &Map<String, Value>) -> Value {
143        match self.respond(method, params) {
144            Ok(result) => framing::result(id, &result),
145            Err(refusal) => framing::error(id, refusal.code, &refusal.message, refusal.data),
146        }
147    }
148
149    fn respond(&mut self, method: &str, params: &Map<String, Value>) -> Result<Value, Refusal> {
150        match method {
151            "initialize" => return self.session.initialize(params),
152            "server/discover" => {
153                self.session.era_of(params)?;
154                return Ok(Session::discover());
155            }
156            _ => {}
157        }
158        let era = self.session.era_of(params)?;
159        match method {
160            "ping" => Ok(shaped(era, Map::new())),
161            "logging/setLevel" => {
162                super::notifications::set_level(params, &self.warning_only)?;
163                Ok(shaped(era, Map::new()))
164            }
165            "tools/list" => {
166                let mut result = Map::new();
167                result.insert("tools".to_owned(), tool_table());
168                if era == Era::Modern {
169                    // 2026-07-28 list results carry their cache terms. The
170                    // table is the same for every pass (what a call may reach
171                    // is decided per call), so any client may share it; a
172                    // minute lets a replaced binary's tools show promptly.
173                    result.insert("ttlMs".to_owned(), json!(TOOLS_TTL_MS));
174                    result.insert("cacheScope".to_owned(), json!("public"));
175                }
176                Ok(shaped(era, result))
177            }
178            "tools/call" => {
179                let name = params
180                    .get("name")
181                    .and_then(Value::as_str)
182                    .ok_or_else(|| Refusal {
183                        code: INVALID_PARAMS,
184                        message: "the request is missing name".to_owned(),
185                        data: None,
186                    })?;
187                let arguments = params
188                    .get("arguments")
189                    .and_then(Value::as_object)
190                    .cloned()
191                    .unwrap_or_default();
192                let outcome = self.tool(name, &arguments)?;
193                Ok(outcome.into_result(era))
194            }
195            other => Err(Refusal {
196                code: METHOD_NOT_FOUND,
197                message: format!("the ticket has no method {other}"),
198                data: None,
199            }),
200        }
201    }
202
203    /// One tool's outcome; an unknown tool is method-not-found.
204    ///
205    /// # Errors
206    /// Returns the method-not-found or invalid-params refusal.
207    pub fn tool(&self, name: &str, arguments: &Map<String, Value>) -> Result<ToolResult, Refusal> {
208        if name == "send" {
209            return super::channel_send::send(self, arguments);
210        }
211        if matches!(name, "listen" | "unlisten") {
212            let subject = super::listen::argument(arguments)?;
213            if name == "unlisten" {
214                return Ok(self.listens.unlisten(subject));
215            }
216            let Some(source) = &self.channel_source else {
217                return Ok(ToolResult::failed(
218                    "the ticket has no seat configuration for channel tools",
219                    json!({"subject":subject}),
220                ));
221            };
222            return Ok(self.listens.listen(source, &self.listen_config, subject));
223        }
224        if matches!(name, "subscribe" | "unsubscribe") {
225            let service = super::subscriptions::service_argument(arguments)?;
226            return Ok(self.subscriptions.tool(&self.rail, name, service));
227        }
228        if let Some(outcome) = crate::network::lifecycle::tool(&self.rail, name, arguments) {
229            return Ok(outcome);
230        }
231        if let Some(outcome) = super::plan_tools::tool(&self.rail, name, arguments)
232            .or_else(|| super::template_tools::tool(&self.rail, name, arguments))
233        {
234            return Ok(outcome);
235        }
236        let outcome = match name {
237            "list" => directory::list(&self.rail),
238            "describe" => {
239                let subject = string(arguments, "subject")?;
240                directory::describe(&self.rail, subject)
241            }
242            "call" => {
243                let subject = string(arguments, "subject")?;
244                let input = object(arguments, "input")?;
245                let reply = arguments
246                    .get("reply")
247                    .and_then(Value::as_bool)
248                    .unwrap_or(true);
249                let pass_for = match arguments.get("pass_for") {
250                    None => None,
251                    Some(Value::String(audience)) => Some(audience.as_str()),
252                    Some(_) => {
253                        return Err(Refusal {
254                            code: INVALID_PARAMS,
255                            message: "the tool call's pass_for is not a string".to_owned(),
256                            data: None,
257                        });
258                    }
259                };
260                call::call(&self.rail, subject, input, reply, pass_for)
261            }
262            "flow" => {
263                let steps = arguments
264                    .get("steps")
265                    .and_then(Value::as_array)
266                    .ok_or_else(|| Refusal {
267                        code: INVALID_PARAMS,
268                        message: "the flow is missing steps".to_owned(),
269                        data: None,
270                    })?;
271                let last = match arguments.get("answer").and_then(Value::as_str) {
272                    None | Some("all") => false,
273                    Some("last") => true,
274                    Some(other) => {
275                        return Err(Refusal {
276                            code: INVALID_PARAMS,
277                            message: format!("the flow's answer is {other}; it is all or last"),
278                            data: None,
279                        });
280                    }
281                };
282                flow::flow_answering(&self.rail, steps, last)
283            }
284            other => {
285                return Err(Refusal {
286                    code: METHOD_NOT_FOUND,
287                    message: format!("the ticket has no tool {other}"),
288                    data: None,
289                });
290            }
291        };
292        Ok(outcome)
293    }
294}