1mod extensions;
9mod protocol;
10mod services;
11mod transport;
12pub use extensions::UiToolProvider;
13use ferroui_base::threading::{Dispatcher, DispatcherPriority};
14use ferroui_inspect::LogCapture;
15pub use ferroui_inspect::Permissions;
16use serde_json::Value;
17pub use services::{RequestContext, ServiceToolProvider};
18use std::{
19 io,
20 net::{IpAddr, Ipv4Addr, SocketAddr},
21 sync::{
22 atomic::{AtomicBool, Ordering},
23 mpsc, Arc,
24 },
25 time::{Duration, Instant},
26};
27
28#[derive(Clone, Debug)]
30pub enum Transport {
31 Stdio,
32 Http(SocketAddr),
33}
34pub struct Options {
35 pub transport: Transport,
36 pub permissions: Permissions,
37 pub capture_logs: bool,
40 pub stdio_output: Option<std::fs::File>,
42 pub dispatch_timeout: Duration,
44}
45impl Default for Options {
46 fn default() -> Self {
47 Self {
48 transport: Transport::Http(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)),
49 permissions: Permissions::default(),
50 capture_logs: true,
51 stdio_output: None,
52 dispatch_timeout: Duration::from_secs(10),
53 }
54 }
55}
56static STDIO_STARTED: AtomicBool = AtomicBool::new(false);
57
58pub struct ServerHandle {
62 client: Client,
63 log_guard: Option<ferroui_inspect::LogGuard>,
64 pub address: Option<SocketAddr>,
65 pub token: Option<String>,
67}
68impl ServerHandle {
69 pub fn is_running(&self) -> bool {
70 !self.client.stop.load(Ordering::Acquire)
71 }
72}
73impl Drop for ServerHandle {
74 fn drop(&mut self) {
75 self.client.stop.store(true, Ordering::Release);
76 self.log_guard.take();
77 }
78}
79
80pub struct UiServerHandle {
85 server: ServerHandle,
87 _registration: extensions::Registration,
88}
89impl std::ops::Deref for UiServerHandle {
90 type Target = ServerHandle;
91
92 fn deref(&self) -> &Self::Target {
93 &self.server
94 }
95}
96
97pub fn serve(options: Options) -> io::Result<ServerHandle> {
99 serve_registered(options, None, None, None)
100}
101
102pub fn serve_with_tools(
105 options: Options,
106 provider: std::rc::Rc<dyn UiToolProvider>,
107) -> io::Result<UiServerHandle> {
108 let registration = extensions::Registration::new(provider)?;
109 let server = serve_registered(options, Some(registration.catalog.clone()), None, None)?;
110 Ok(UiServerHandle {
111 server,
112 _registration: registration,
113 })
114}
115
116pub fn serve_service(
122 options: Options,
123 provider: Arc<dyn ServiceToolProvider>,
124) -> io::Result<ServerHandle> {
125 let service = services::ServiceCatalog::new(provider)?;
126 serve_registered(options, None, Some(service), None)
127}
128
129pub fn serve_service_with_token(
132 options: Options,
133 provider: Arc<dyn ServiceToolProvider>,
134 bearer: String,
135) -> io::Result<ServerHandle> {
136 if bearer.len() != 64 || !bearer.bytes().all(|byte| byte.is_ascii_hexdigit()) {
137 return Err(io::Error::new(
138 io::ErrorKind::InvalidInput,
139 "Service bearer must be 256-bit hexadecimal",
140 ));
141 }
142 let service = services::ServiceCatalog::new(provider)?;
143 serve_registered(options, None, Some(service), Some(bearer))
144}
145
146fn serve_registered(
147 options: Options,
148 extension: Option<extensions::Catalog>,
149 service: Option<services::ServiceCatalog>,
150 bearer: Option<String>,
151) -> io::Result<ServerHandle> {
152 let dispatcher = service.is_none().then(|| {
153 let dispatcher = Dispatcher::ui_thread();
154 dispatcher.verify_access();
155 dispatcher
156 });
157 if options.dispatch_timeout.is_zero() {
158 return Err(io::Error::new(
159 io::ErrorKind::InvalidInput,
160 "dispatch_timeout must be positive",
161 ));
162 }
163 let stdio = matches!(options.transport, Transport::Stdio);
164 if stdio && STDIO_STARTED.swap(true, Ordering::AcqRel) {
165 return Err(io::Error::new(
166 io::ErrorKind::AlreadyExists,
167 "Only one stdio server may start per process",
168 ));
169 }
170 let result = start(options, dispatcher, extension, service, bearer);
172 if stdio && result.is_err() {
173 STDIO_STARTED.store(false, Ordering::Release);
174 }
175 result
176}
177
178fn start(
179 options: Options,
180 dispatcher: Option<Arc<Dispatcher>>,
181 extension: Option<extensions::Catalog>,
182 service: Option<services::ServiceCatalog>,
183 bearer: Option<String>,
184) -> io::Result<ServerHandle> {
185 let (http, address, token) = match &options.transport {
186 Transport::Http(address) => {
187 if !address.ip().is_loopback() {
188 return Err(io::Error::new(
189 io::ErrorKind::PermissionDenied,
190 "MCP HTTP must bind to loopback",
191 ));
192 }
193 let server = std::net::TcpListener::bind(address)?;
194 let address = server.local_addr()?;
195 let mut bytes = [0u8; 32];
196 getrandom::fill(&mut bytes).map_err(|e| io::Error::other(e.to_string()))?;
197 let token = bearer
198 .unwrap_or_else(|| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>());
199 (Some(server), Some(address), Some(token))
200 }
201 Transport::Stdio => (None, None, None),
202 };
203 let stdio_output = if http.is_none() {
204 Some(match options.stdio_output {
205 Some(file) => file,
206 None => reserve_stdout()?,
207 })
208 } else {
209 None
210 };
211 let log_guard = (options.capture_logs && service.is_none()).then(LogCapture::install);
212 let is_service = service.is_some();
213 let client = Client {
214 dispatcher,
215 extension,
216 service,
217 permissions: options.permissions,
218 timeout: options.dispatch_timeout,
219 stop: Arc::new(AtomicBool::new(false)),
220 };
221 let handle = ServerHandle {
222 client: client.clone(),
223 log_guard,
224 address,
225 token: token.clone(),
226 };
227 let task = move || {
228 let stop = client.stop.clone();
229 if let Some(server) = http {
230 transport::http(server, client, token.unwrap());
231 } else if let Err(e) = transport::stdio(client, stdio_output.unwrap()) {
232 eprintln!("FerroUI MCP stdio: {e}");
233 }
234 stop.store(true, Ordering::Release);
235 };
236 std::thread::Builder::new()
237 .name("FerroUI MCP".into())
238 .spawn(task)?;
239 if !is_service {
240 if let (Some(address), Some(token)) = (handle.address, &handle.token) {
241 eprintln!("FerroUI MCP: http://{address}/mcp\nAuthorization: Bearer {token}");
242 }
243 }
244 Ok(handle)
245}
246
247#[derive(Clone)]
248pub(crate) struct Client {
249 dispatcher: Option<Arc<Dispatcher>>,
250 extension: Option<extensions::Catalog>,
251 service: Option<services::ServiceCatalog>,
252 permissions: Permissions,
253 timeout: Duration,
254 stop: Arc<AtomicBool>,
255}
256impl Client {
257 fn tools(&self) -> Vec<Value> {
258 if let Some(service) = &self.service {
259 return service.tools().to_vec();
260 }
261 let mut tools = ferroui_inspect::tools();
262 if let Some(extension) = &self.extension {
263 tools.extend(extension.tools.iter().cloned());
264 }
265 tools
266 }
267 fn validate(&self, name: &str, args: &Value) -> Result<(), String> {
268 if let Some(service) = &self.service {
269 return service.validate(name, args);
270 }
271 if let Some(extension) = self
272 .extension
273 .as_ref()
274 .filter(|extension| extension.contains(name))
275 {
276 extension.validate_envelope(name, args)
277 } else {
278 ferroui_inspect::validate(name, args)
279 }
280 }
281 fn call(&self, name: &str, args: Value, cancel: &Arc<AtomicBool>) -> Result<Value, String> {
282 self.validate(name, &args)?;
283 if let Some(service) = &self.service {
284 let context = RequestContext::new(
285 self.permissions,
286 cancel.clone(),
287 self.stop.clone(),
288 self.timeout,
289 );
290 context.check_cancelled()?;
291 return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
292 service.execute(name, &args, &context)
293 }))
294 .unwrap_or_else(|_| Err("Service operation panicked".into()));
295 }
296 let wait_started = Instant::now();
300 let wait_timeout = Duration::from_millis(args["timeout_ms"].as_u64().unwrap_or(5000));
301 loop {
302 if self.stop.load(Ordering::Acquire) || cancel.load(Ordering::Acquire) {
303 return Err("Request cancelled or server stopped".into());
304 }
305 let (tx, rx) = mpsc::sync_channel(1);
306 let permissions = self.permissions;
307 let extension = self
308 .extension
309 .clone()
310 .filter(|extension| extension.contains(name));
311 let tool = name.to_owned();
312 let args = args.clone();
313 let cancelled = cancel.clone();
314 let stop = self.stop.clone();
315 let pending = Arc::new(AtomicBool::new(true));
316 let queued = pending.clone();
317 self.dispatcher
318 .as_ref()
319 .expect("UI client owns a dispatcher")
320 .post(
321 move || {
322 if !queued.swap(false, Ordering::AcqRel)
323 || cancelled.load(Ordering::Acquire)
324 || stop.load(Ordering::Acquire)
325 {
326 return;
327 }
328 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
329 if let Some(extension) = extension {
330 extension.execute(&tool, &args, permissions)
331 } else {
332 ferroui_inspect::execute(&tool, &args, permissions)
333 }
334 }))
335 .unwrap_or_else(|_| Err("UI operation panicked".into()));
336 let _ = tx.send(result);
337 },
338 DispatcherPriority::BACKGROUND,
339 );
340 let queued_at = Instant::now();
341 let result = loop {
342 if self.stop.load(Ordering::Acquire) || cancel.load(Ordering::Acquire) {
343 pending.store(false, Ordering::Release);
344 return Err("Request cancelled or server stopped".into());
345 }
346 let remaining = self.timeout.saturating_sub(queued_at.elapsed());
347 if remaining.is_zero() {
348 pending.store(false, Ordering::Release);
349 return Err("UI dispatcher timed out".into());
350 }
351 match rx.recv_timeout(remaining.min(Duration::from_millis(25))) {
352 Ok(result) => break result?,
353 Err(mpsc::RecvTimeoutError::Timeout) => continue,
354 Err(mpsc::RecvTimeoutError::Disconnected) => {
355 return Err("UI operation cancelled".into())
356 }
357 }
358 };
359 if name != "wait_for" || result["matched"] == true {
360 return Ok(result);
361 }
362 if wait_started.elapsed() >= wait_timeout {
363 return Err("Condition timed out".into());
364 }
365 std::thread::sleep(Duration::from_millis(25));
366 }
367 }
368}
369
370pub fn reserve_stdout() -> io::Result<std::fs::File> {
374 use std::io::Write;
375 std::io::stdout().flush()?;
376 let original = filedescriptor::FileDescriptor::redirect_stdio(
377 &std::io::stderr(),
378 filedescriptor::StdioDescriptor::Stdout,
379 )
380 .map_err(io::Error::other)?;
381 #[cfg(unix)]
384 {
385 use std::os::fd::{FromRawFd, IntoRawFd};
386 Ok(unsafe { std::fs::File::from_raw_fd(original.into_raw_fd()) })
388 }
389 #[cfg(windows)]
390 {
391 use std::os::windows::io::{FromRawHandle, IntoRawHandle};
392 Ok(unsafe { std::fs::File::from_raw_handle(original.into_raw_handle()) })
394 }
395}
396
397#[cfg(test)]
398mod extensions_tests;
399#[cfg(test)]
400mod tests;