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