1mod rpc;
19mod tools;
20
21use std::io::Read;
22use std::path::PathBuf;
23use std::sync::{Arc, Mutex};
24use std::thread::JoinHandle;
25
26use falcon_library::undo::Transaction;
27use serde_json::{Value, json};
28use tiny_http::{Header, Method, Request, Response, Server};
29
30use crate::{EAGLE_PORT, Shared};
31
32pub const DEFAULT_PORT: u16 = 41610;
35pub const RESERVED_PORT: u16 = 8000;
38pub const PATH: &str = "/mcp";
40
41const MAX_BODY: u64 = 4 * 1024 * 1024;
43const WORKERS: usize = 4;
45const MAX_PENDING_UNDO: usize = 100;
47
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct Options {
51 pub allow_writes: bool,
53 pub token: Option<String>,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum WriteKind {
61 AddItem,
62 UpdateItem,
63 CreateFolder,
64 MoveToTrash,
65}
66
67#[derive(Debug)]
69pub struct UndoStep {
70 pub root: PathBuf,
72 pub kind: WriteKind,
73 pub transaction: Transaction,
74}
75
76struct Context {
78 library: Mutex<Shared>,
80 options: Mutex<Options>,
81 undo: Mutex<Vec<UndoStep>>,
82 port: u16,
83}
84
85impl Context {
86 fn library(&self) -> Shared {
87 self.library.lock().unwrap_or_else(|p| p.into_inner()).clone()
88 }
89
90 fn options(&self) -> Options {
91 self.options.lock().unwrap_or_else(|p| p.into_inner()).clone()
92 }
93
94 fn push_undo(&self, step: UndoStep) {
95 let mut undo = self.undo.lock().unwrap_or_else(|p| p.into_inner());
96 undo.push(step);
97 if undo.len() > MAX_PENDING_UNDO {
98 undo.remove(0);
99 }
100 }
101}
102
103pub struct Handle {
105 server: Arc<Server>,
106 threads: Vec<JoinHandle<()>>,
107 context: Arc<Context>,
108 pub port: u16,
109}
110
111impl Handle {
112 pub fn set_library(&self, lib: Shared) {
114 *self.context.library.lock().unwrap_or_else(|p| p.into_inner()) = lib;
115 }
116
117 pub fn set_options(&self, options: Options) {
119 *self.context.options.lock().unwrap_or_else(|p| p.into_inner()) = options;
120 }
121
122 pub fn take_undo(&self) -> Vec<UndoStep> {
124 std::mem::take(&mut *self.context.undo.lock().unwrap_or_else(|p| p.into_inner()))
125 }
126}
127
128impl Drop for Handle {
129 fn drop(&mut self) {
130 for _ in &self.threads {
132 self.server.unblock();
133 }
134 for t in self.threads.drain(..) {
135 let _ = t.join();
136 }
137 }
138}
139
140pub fn start(
144 lib: Shared,
145 ports: impl IntoIterator<Item = u16>,
146 options: Options,
147 on_request: impl Fn() + Send + Sync + 'static,
148) -> std::io::Result<Handle> {
149 let mut last = None;
150 let on_request = Arc::new(on_request);
151 for port in ports {
152 if port == EAGLE_PORT || port == RESERVED_PORT {
153 last = Some(format!("port {port} is not used"));
154 continue;
155 }
156 let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
159 if port != 0 && std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok() {
160 last = Some(format!("port {port} is in use"));
161 continue;
162 }
163 let server = match Server::http(("127.0.0.1", port)) {
164 Ok(server) => Arc::new(server),
165 Err(e) => {
166 last = Some(e.to_string());
167 continue;
168 }
169 };
170 let port = server.server_addr().to_ip().map_or(port, |a| a.port());
171 let context = Arc::new(Context { library: Mutex::new(lib), options: Mutex::new(options), undo: Mutex::new(Vec::new()), port });
172 let threads = (0..WORKERS)
173 .map(|_| {
174 let (server, context, on_request) = (server.clone(), context.clone(), on_request.clone());
175 std::thread::spawn(move || {
176 for request in server.incoming_requests() {
177 handle(&context, request);
178 on_request();
179 }
180 })
181 })
182 .collect();
183 return Ok(Handle { server, threads, context, port });
184 }
185 Err(std::io::Error::other(last.unwrap_or_else(|| "no ports given".to_string())))
186}
187
188pub fn client_config(port: u16, token: Option<&str>) -> String {
190 let mut server = json!({"type": "http", "url": format!("http://127.0.0.1:{port}{PATH}")});
191 if let Some(t) = token {
192 server["headers"] = json!({"Authorization": format!("Bearer {t}")});
193 }
194 let config = json!({"mcpServers": {"falcon": server}});
195 serde_json::to_string_pretty(&config).unwrap_or_default()
196}
197
198struct Reply {
200 status: u16,
201 body: Option<Value>,
202 header: Option<(&'static str, &'static str)>,
204}
205
206impl Reply {
207 fn refused(status: u16, message: &str) -> Reply {
209 Reply { status, body: Some(rpc::error_body(None, rpc::INVALID_REQUEST, message, None)), header: None }
210 }
211}
212
213fn handle(context: &Context, mut request: Request) {
214 let reply = respond(context, &mut request);
215 let has_body = reply.body.is_some();
216 let body = reply.body.map_or_else(String::new, |b| b.to_string());
217 let mut response = Response::from_string(body).with_status_code(reply.status);
218 let kind = if has_body { "application/json" } else { "text/plain" };
220 if let Ok(h) = Header::from_bytes("Content-Type", kind) {
221 response.add_header(h);
222 }
223 if let Some(h) = reply.header.and_then(|(k, v)| Header::from_bytes(k, v).ok()) {
224 response.add_header(h);
225 }
226 let _ = request.respond(response);
227}
228
229fn header<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> {
230 request.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.as_str())
231}
232
233fn respond(context: &Context, request: &mut Request) -> Reply {
234 let options = context.options();
235 let origins_ok = request.headers().iter().filter(|h| h.field.equiv("Origin")).all(|h| origin_allowed(h.value.as_str()));
236 if !origins_ok || !host_allowed(header(request, "Host"), context.port) {
237 return Reply::refused(403, "This address or origin is not allowed.");
238 }
239 if !token_allowed(options.token.as_deref(), header(request, "Authorization")) {
240 return Reply { header: Some(("WWW-Authenticate", "Bearer")), ..Reply::refused(401, "A valid bearer token is required.") };
241 }
242 let url = request.url();
243 let path = url.split(['?', '#']).next().unwrap_or(url).trim_end_matches('/');
244 if path != PATH {
245 return Reply::refused(404, "The MCP endpoint is /mcp.");
246 }
247 if *request.method() != Method::Post {
248 return Reply { header: Some(("Allow", "POST")), ..Reply::refused(405, "Only POST is served.") };
249 }
250 if !is_json(header(request, "Content-Type")) {
251 return Reply::refused(415, "The body must be application/json.");
252 }
253 if !accepts_json(header(request, "Accept")) {
254 return Reply::refused(406, "Replies are application/json.");
255 }
256 let headers = rpc::Headers {
257 protocol: header(request, "MCP-Protocol-Version").map(String::from),
258 method: header(request, "Mcp-Method").map(String::from),
259 name: header(request, "Mcp-Name").map(String::from),
260 };
261 let mut body = Vec::new();
262 if request.as_reader().take(MAX_BODY + 1).read_to_end(&mut body).is_err() {
263 return Reply::refused(400, "The body could not be read.");
264 }
265 if body.len() as u64 > MAX_BODY {
266 return Reply::refused(413, "The body is too large.");
267 }
268 let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rpc::dispatch(context, &options, &headers, &body)));
270 let (status, body) = done.unwrap_or_else(|_| (500, Some(rpc::error_body(None, rpc::INTERNAL_ERROR, "The request failed.", None))));
271 Reply { status, body, header: None }
272}
273
274fn host_allowed(host: Option<&str>, port: u16) -> bool {
277 host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h.eq_ignore_ascii_case(&format!("localhost:{port}")))
278}
279
280fn origin_allowed(origin: &str) -> bool {
283 let origin = origin.to_ascii_lowercase();
284 let Some(rest) = origin.strip_prefix("http://").or_else(|| origin.strip_prefix("https://")) else { return false };
285 ["localhost", "127.0.0.1", "[::1]"].iter().any(|host| {
286 rest.strip_prefix(host).is_some_and(|tail| {
287 tail.is_empty() || tail.strip_prefix(':').is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
288 })
289 })
290}
291
292fn token_allowed(token: Option<&str>, authorization: Option<&str>) -> bool {
295 let Some(token) = token else { return true };
296 let given = authorization.and_then(|a| a.split_once(' ')).filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")).map(|(_, t)| t.trim());
297 !token.is_empty() && given.is_some_and(|g| same_bytes(g.as_bytes(), token.as_bytes()))
298}
299
300fn same_bytes(a: &[u8], b: &[u8]) -> bool {
302 a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0
303}
304
305fn is_json(content_type: Option<&str>) -> bool {
306 content_type.is_some_and(|c| c.split(';').next().unwrap_or("").trim().eq_ignore_ascii_case("application/json"))
307}
308
309fn accepts_json(accept: Option<&str>) -> bool {
312 accept.is_none_or(|a| {
313 a.split(',').any(|m| {
314 let m = m.split(';').next().unwrap_or("").trim();
315 m.eq_ignore_ascii_case("application/json") || m.eq_ignore_ascii_case("application/*") || m == "*/*"
316 })
317 })
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn origins() {
326 for good in ["http://localhost", "http://localhost:3000", "http://127.0.0.1:6274", "https://LOCALHOST:1", "http://[::1]:80"] {
327 assert!(origin_allowed(good), "{good}");
328 }
329 for bad in ["null", "", "http://evil.example", "http://localhost.evil.example", "http://127.0.0.1.evil.example:80", "http://localhost:", "http://localhost:80/x", "http://localhost@evil.example", "file://", "chrome-extension://abc", "http://192.168.1.2"] {
330 assert!(!origin_allowed(bad), "{bad}");
331 }
332 }
333
334 #[test]
335 fn hosts() {
336 assert!(host_allowed(Some("127.0.0.1:41610"), 41610));
337 assert!(host_allowed(Some("localhost:41610"), 41610));
338 assert!(!host_allowed(Some("localhost:41611"), 41610));
339 assert!(!host_allowed(Some("evil.example:41610"), 41610));
340 assert!(!host_allowed(None, 41610));
341 }
342
343 #[test]
344 fn tokens() {
345 assert!(token_allowed(None, None));
346 assert!(token_allowed(Some("abc"), Some("Bearer abc")));
347 assert!(token_allowed(Some("abc"), Some("bearer abc")));
348 assert!(!token_allowed(Some("abc"), None));
349 assert!(!token_allowed(Some("abc"), Some("Bearer abd")));
350 assert!(!token_allowed(Some("abc"), Some("Basic abc")));
351 assert!(!token_allowed(Some(""), Some("Bearer ")), "an empty token matches nothing");
352 }
353
354 #[test]
355 fn media_types() {
356 assert!(is_json(Some("application/json; charset=utf-8")));
357 assert!(!is_json(Some("text/plain")));
358 assert!(!is_json(None));
359 assert!(accepts_json(None));
360 assert!(accepts_json(Some("application/json, text/event-stream")));
361 assert!(accepts_json(Some("*/*")));
362 assert!(!accepts_json(Some("text/event-stream")));
363 }
364
365 #[test]
366 fn config_names_the_address_and_any_token() {
367 let plain: Value = serde_json::from_str(&client_config(41610, None)).unwrap();
368 assert_eq!(plain["mcpServers"]["falcon"]["url"], "http://127.0.0.1:41610/mcp");
369 assert!(plain["mcpServers"]["falcon"].get("headers").is_none());
370 let with: Value = serde_json::from_str(&client_config(41610, Some("abc"))).unwrap();
371 assert_eq!(with["mcpServers"]["falcon"]["headers"]["Authorization"], "Bearer abc");
372 }
373}