Skip to main content

falcon_server/mcp/
mod.rs

1//! A Model Context Protocol server on localhost, so AI assistants on the same
2//! Mac can use the library falcon has open.
3//!
4//! It speaks Streamable HTTP on `POST /mcp`, and answers every request with a
5//! single JSON object (no SSE stream, no sessions). [`rpc`] holds the JSON-RPC
6//! layer and [`tools`] the tools.
7//!
8//! Falcon's own choices:
9//! - it listens on 127.0.0.1 only and never binds Eagle's port or 8000;
10//! - a request is served only when its `Host` is the server's own address and
11//!   its `Origin`, if it has one, is on the loopback address. There are no CORS
12//!   headers, so a web page can't read an answer even from another local port;
13//! - [`Options::token`] can also ask for `Authorization: Bearer <token>`;
14//! - it serves whatever library [`Handle::set_library`] was last given and has
15//!   no way to open another one;
16//! - write tools exist only while [`Options::allow_writes`] is on.
17
18mod 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
32/// The first port tried. The HTTP API tries the five from
33/// [`crate::DEFAULT_PORT`], so this stays clear of them.
34pub const DEFAULT_PORT: u16 = 41610;
35/// A port the MCP server never binds, besides Eagle's: other MCP servers on
36/// this Mac commonly sit there.
37pub const RESERVED_PORT: u16 = 8000;
38/// The MCP endpoint.
39pub const PATH: &str = "/mcp";
40
41/// Requests carry JSON-RPC text, so this is far more than any of them needs.
42const MAX_BODY: u64 = 4 * 1024 * 1024;
43/// Requests served at once, so a slow import doesn't hold up reads.
44const WORKERS: usize = 4;
45/// Undo steps kept for a host that has not taken them yet.
46const MAX_PENDING_UNDO: usize = 100;
47
48/// What the app can change on a running server.
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct Options {
51    /// Whether the write tools are listed and callable.
52    pub allow_writes: bool,
53    /// A token every request must send as `Authorization: Bearer`. An empty
54    /// one matches nothing, so no request is served.
55    pub token: Option<String>,
56}
57
58/// Which write tool made an [`UndoStep`].
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum WriteKind {
61    AddItem,
62    UpdateItem,
63    CreateFolder,
64    MoveToTrash,
65}
66
67/// A write a tool made, for the app's undo history.
68#[derive(Debug)]
69pub struct UndoStep {
70    /// The library it changed.
71    pub root: PathBuf,
72    pub kind: WriteKind,
73    pub transaction: Transaction,
74}
75
76/// What the workers share.
77struct Context {
78    /// The library being served; [`Handle::set_library`] swaps it.
79    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
103/// A server running on its own threads; dropping it stops the server.
104pub struct Handle {
105    server: Arc<Server>,
106    threads: Vec<JoinHandle<()>>,
107    context: Arc<Context>,
108    pub port: u16,
109}
110
111impl Handle {
112    /// Serves `lib` from the next request on, keeping the port.
113    pub fn set_library(&self, lib: Shared) {
114        *self.context.library.lock().unwrap_or_else(|p| p.into_inner()) = lib;
115    }
116
117    /// Applies new options from the next request on, keeping the port.
118    pub fn set_options(&self, options: Options) {
119        *self.context.options.lock().unwrap_or_else(|p| p.into_inner()) = options;
120    }
121
122    /// The writes made since the last call, oldest first.
123    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        // Each call wakes one blocked worker.
131        for _ in &self.threads {
132            self.server.unblock();
133        }
134        for t in self.threads.drain(..) {
135            let _ = t.join();
136        }
137    }
138}
139
140/// Starts serving on the first free port of `ports`, calling `on_request`
141/// after each handled request (for example to repaint a UI). Port 0 asks the
142/// system for a free port.
143pub 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        // As for the HTTP API: a 127.0.0.1 bind can succeed beside a wildcard
157        // listener on macOS, so skip any port where something already answers.
158        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
188/// The JSON an MCP client is configured with to reach this server.
189pub 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
198/// What goes back over HTTP.
199struct Reply {
200    status: u16,
201    body: Option<Value>,
202    /// `(name, value)` of one more header.
203    header: Option<(&'static str, &'static str)>,
204}
205
206impl Reply {
207    /// A refusal, as a JSON-RPC error with no id.
208    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    // `from_string` starts as text/plain.
219    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    // A decoder's panic on a bad file must not take a worker with it.
269    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
274/// Whether `Host` names this server. It stops DNS rebinding even from a
275/// client that sends no `Origin`.
276fn 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
280/// Whether an `Origin` is on the loopback address, at any port. `null` (a
281/// sandboxed page or a local file) is not.
282fn 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
292/// Whether a request has the token, when one is asked for. An empty token
293/// matches nothing.
294fn 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
300/// Compares without stopping at the first difference.
301fn 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
309/// A client must offer JSON and SSE. Answers are always JSON, so only one
310/// that rules JSON out is turned away.
311fn 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}