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.
10#[derive(Debug)]
11pub struct Server<R> {
12    rail: R,
13    session: Session,
14    pub(super) frame_bytes: usize,
15    pub(super) subscriptions: std::sync::Arc<super::subscriptions::Subscriptions>,
16    pub(super) warning_only: std::sync::Arc<std::sync::atomic::AtomicBool>,
17}
18
19impl<R: Rail> Server<R> {
20    /// A server over `rail`, reading lines of at most `frame_bytes`.
21    #[must_use]
22    pub fn new(rail: R, frame_bytes: usize) -> Self {
23        Self {
24            rail,
25            session: Session::new(),
26            frame_bytes,
27            subscriptions: std::sync::Arc::default(),
28            warning_only: std::sync::Arc::default(),
29        }
30    }
31
32    /// The rail behind the tools.
33    #[must_use]
34    pub const fn rail(&self) -> &R {
35        &self.rail
36    }
37
38    /// One request's answer.
39    pub fn answer(&mut self, id: &Value, method: &str, params: &Map<String, Value>) -> Value {
40        match self.respond(method, params) {
41            Ok(result) => framing::result(id, &result),
42            Err(refusal) => framing::error(id, refusal.code, &refusal.message, refusal.data),
43        }
44    }
45
46    fn respond(&mut self, method: &str, params: &Map<String, Value>) -> Result<Value, Refusal> {
47        match method {
48            "initialize" => return self.session.initialize(params),
49            "server/discover" => {
50                self.session.era_of(params)?;
51                return Ok(Session::discover());
52            }
53            _ => {}
54        }
55        let era = self.session.era_of(params)?;
56        match method {
57            "ping" => Ok(shaped(era, Map::new())),
58            "logging/setLevel" => {
59                super::notifications::set_level(params, &self.warning_only)?;
60                Ok(shaped(era, Map::new()))
61            }
62            "tools/list" => {
63                let mut result = Map::new();
64                result.insert("tools".to_owned(), tool_table());
65                if era == Era::Modern {
66                    // 2026-07-28 list results carry their cache terms. The
67                    // table is the same for every pass (what a call may reach
68                    // is decided per call), so any client may share it; a
69                    // minute lets a replaced binary's tools show promptly.
70                    result.insert("ttlMs".to_owned(), json!(TOOLS_TTL_MS));
71                    result.insert("cacheScope".to_owned(), json!("public"));
72                }
73                Ok(shaped(era, result))
74            }
75            "tools/call" => {
76                let name = params
77                    .get("name")
78                    .and_then(Value::as_str)
79                    .ok_or_else(|| Refusal {
80                        code: INVALID_PARAMS,
81                        message: "the request is missing name".to_owned(),
82                        data: None,
83                    })?;
84                let arguments = params
85                    .get("arguments")
86                    .and_then(Value::as_object)
87                    .cloned()
88                    .unwrap_or_default();
89                let outcome = self.tool(name, &arguments)?;
90                Ok(outcome.into_result(era))
91            }
92            other => Err(Refusal {
93                code: METHOD_NOT_FOUND,
94                message: format!("the ticket has no method {other}"),
95                data: None,
96            }),
97        }
98    }
99
100    /// One tool's outcome; an unknown tool is method-not-found.
101    ///
102    /// # Errors
103    /// Returns the method-not-found or invalid-params refusal.
104    pub fn tool(&self, name: &str, arguments: &Map<String, Value>) -> Result<ToolResult, Refusal> {
105        if matches!(name, "subscribe" | "unsubscribe") {
106            let service = super::subscriptions::service_argument(arguments)?;
107            return Ok(self.subscriptions.tool(&self.rail, name, service));
108        }
109        if let Some(outcome) = crate::network::lifecycle::tool(&self.rail, name, arguments) {
110            return Ok(outcome);
111        }
112        let outcome = match name {
113            "list" => directory::list(&self.rail),
114            "describe" => {
115                let subject = string(arguments, "subject")?;
116                directory::describe(&self.rail, subject)
117            }
118            "call" => {
119                let subject = string(arguments, "subject")?;
120                let input = object(arguments, "input")?;
121                let reply = arguments
122                    .get("reply")
123                    .and_then(Value::as_bool)
124                    .unwrap_or(true);
125                let pass_for = match arguments.get("pass_for") {
126                    None => None,
127                    Some(Value::String(audience)) => Some(audience.as_str()),
128                    Some(_) => {
129                        return Err(Refusal {
130                            code: INVALID_PARAMS,
131                            message: "the tool call's pass_for is not a string".to_owned(),
132                            data: None,
133                        });
134                    }
135                };
136                call::call(&self.rail, subject, input, reply, pass_for)
137            }
138            "flow" => {
139                let steps = arguments
140                    .get("steps")
141                    .and_then(Value::as_array)
142                    .ok_or_else(|| Refusal {
143                        code: INVALID_PARAMS,
144                        message: "the flow is missing steps".to_owned(),
145                        data: None,
146                    })?;
147                flow::flow(&self.rail, steps)
148            }
149            other => {
150                return Err(Refusal {
151                    code: METHOD_NOT_FOUND,
152                    message: format!("the ticket has no tool {other}"),
153                    data: None,
154                });
155            }
156        };
157        Ok(outcome)
158    }
159}