mobux 0.51.0

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! The MCP server agents on the host reach at `http://127.0.0.1:<port>/mcp`
//! (issue #334, stage 4).
//!
//! It takes no credentials: the loopback bind is the gate, and the router is
//! only ever served on its own loopback listener, never on the public or the
//! Access listener. rmcp refuses a `Host` or `Origin` that is not loopback
//! with a 403, which is what keeps a web page from reaching it through DNS
//! rebinding. Every tool calls the functions the HTTP handlers call.

use std::sync::Arc;

use axum::{http::StatusCode, Router};
use regex::Regex;
use rmcp::{
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::{Implementation, ServerCapabilities, ServerConfig},
    schemars, tool, tool_handler, tool_router,
    transport::streamable_http_server::{
        session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
    },
    ServerHandler,
};
use serde::Deserialize;
use tokio_util::sync::CancellationToken;

use crate::{config, db::Db, push, tmux};

pub const PATH: &str = "/mcp";

const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];

const LOOPBACK_ORIGINS: [&str; 6] = [
    "http://localhost:*",
    "http://127.0.0.1:*",
    "http://[::1]:*",
    "https://localhost:*",
    "https://127.0.0.1:*",
    "https://[::1]:*",
];

/// What the tools need from the running instance.
#[derive(Clone)]
pub struct Context {
    pub session_name: Arc<Regex>,
    pub db: Arc<Db>,
    pub vapid_contact: String,
}

impl Context {
    pub fn new(session_name: Arc<Regex>, db: Arc<Db>, config: &config::Config) -> Self {
        Context {
            session_name,
            db,
            vapid_contact: config.push.vapid_contact.clone(),
        }
    }
}

/// The router serving MCP at [`PATH`]. Mount it on a loopback listener only.
/// Cancelling `shutdown` ends every MCP session it holds.
pub fn router(context: Context, shutdown: CancellationToken) -> Router {
    let config = StreamableHttpServerConfig::default()
        .with_cancellation_token(shutdown)
        .with_allowed_hosts(LOOPBACK_HOSTS)
        .with_allowed_origins(LOOPBACK_ORIGINS)
        .enforce_origin_validation();
    let service = StreamableHttpService::new(
        move || Ok(Mobux::new(context.clone())),
        Arc::new(LocalSessionManager::default()),
        config,
    );
    Router::new().nest_service(PATH, service)
}

/// A 404 at [`PATH`] for every listener that is not the loopback MCP one, so
/// the public fallback never answers there.
pub fn absent<S: Clone + Send + Sync + 'static>() -> Router<S> {
    Router::new().route(
        PATH,
        axum::routing::any(|| async { (StatusCode::NOT_FOUND, "MCP is served on 127.0.0.1 only") }),
    )
}

/// The URL a notification opens. An http(s) URL stays as it is. A path is
/// sent relative, from the instance root, so `/files/site/` and `files/site/`
/// are the same page: the phone's service worker resolves it against its own
/// scope, which keeps the phone on the address it subscribed from.
pub fn link_target(url: &str) -> Result<String, String> {
    let url = url.trim();
    if url.is_empty() {
        return Err("url is required".to_string());
    }
    let lower = url.to_ascii_lowercase();
    if lower.starts_with("http://") || lower.starts_with("https://") {
        return Ok(url.to_string());
    }
    if has_scheme(url) {
        return Err(format!("{url}: only http and https URLs open on the phone"));
    }
    if url.contains('\\') {
        return Err(format!("{url}: a path cannot contain a backslash"));
    }
    Ok(url.trim_start_matches('/').to_string())
}

fn has_scheme(url: &str) -> bool {
    let Some((scheme, _)) = url.split_once(':') else {
        return false;
    };
    let mut chars = scheme.chars();
    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadScreenArgs {
    /// The tmux session name.
    pub session: String,
    /// How many scrollback lines above the screen to include, up to 10000.
    /// Absent or 0 reads the visible screen only.
    pub lines: Option<u32>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RunCommandArgs {
    /// The tmux session name.
    pub session: String,
    /// One of new-window, kill-window, split-h, split-v, next-window,
    /// prev-window, next-pane, prev-pane, kill-pane, zoom-pane.
    pub command: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SendKeysArgs {
    /// The tmux session name.
    pub session: String,
    /// Text typed into the active pane as literal keystrokes. A newline in
    /// it runs the line, even with enter off.
    pub text: String,
    /// Press Enter after the text.
    pub enter: Option<bool>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct NotifyArgs {
    /// Notification title.
    pub title: String,
    /// Notification text.
    pub body: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ShowOnPhoneArgs {
    /// The page to open: an http(s) URL, or a path on this mobux such as
    /// /files/site/ or /proxy/vite/.
    pub url: String,
    /// Notification title.
    pub title: String,
}

#[derive(Clone)]
pub struct Mobux {
    context: Context,
    tool_router: ToolRouter<Self>,
}

impl Mobux {
    fn new(context: Context) -> Self {
        Mobux {
            context,
            tool_router: Self::tool_router(),
        }
    }

    fn session(&self, name: &str) -> Result<(), String> {
        if !crate::is_valid_session_name(&self.context.session_name, name) {
            return Err(format!("invalid session name: {name:?}"));
        }
        Ok(())
    }

    async fn push(&self, payload: push::Payload) -> Result<String, String> {
        let delivery = push::send_to_devices(
            self.context.db.clone(),
            self.context.vapid_contact.clone(),
            payload,
        )
        .await
        .map_err(one_line)?;
        Ok(format!(
            "sent={} failed={} pruned={}",
            delivery.sent, delivery.failed, delivery.pruned
        ))
    }
}

fn one_line(error: anyhow::Error) -> String {
    format!("{error:#}").replace('\n', " ")
}

#[tool_router]
impl Mobux {
    #[tool(
        description = "List the tmux sessions: name, window count, the active window and whether it is on the alternate screen (a full-screen app such as vim or less)."
    )]
    async fn list_sessions(&self) -> Result<String, String> {
        let sessions = tmux::list_sessions(None).await.map_err(one_line)?;
        if sessions.is_empty() {
            return Ok("no tmux sessions".to_string());
        }
        let mut lines = Vec::with_capacity(sessions.len());
        for session in sessions {
            let windows = tmux::list_panes(&session.name, None)
                .await
                .map_err(one_line)?;
            let line = match windows.iter().find(|window| window.active) {
                Some(active) => format!(
                    "{}\twindows={}\tactive={}:{}\talternate_screen={}",
                    session.name,
                    session.windows,
                    active.index,
                    active.title,
                    if active.alternate_on { "yes" } else { "no" }
                ),
                None => format!("{}\twindows={}", session.name, session.windows),
            };
            lines.push(line);
        }
        Ok(lines.join("\n"))
    }

    #[tool(
        description = "Read the active pane of a session as plain text: the visible screen, plus up to `lines` scrollback lines above it."
    )]
    async fn read_screen(
        &self,
        Parameters(args): Parameters<ReadScreenArgs>,
    ) -> Result<String, String> {
        self.session(&args.session)?;
        let lines = args.lines.unwrap_or(0).min(crate::HISTORY_MAX_LINES);
        let capture = tmux::capture_history(&args.session, lines, tmux::HistoryScope::All, None)
            .await
            .map_err(one_line)?;
        Ok(crate::strip_ansi(&capture.text).trim_end().to_string())
    }

    #[tool(
        description = "Run a tmux command on a session: new-window, kill-window, split-h, split-v, next-window, prev-window, next-pane, prev-pane, kill-pane or zoom-pane."
    )]
    async fn run_tmux_command(
        &self,
        Parameters(args): Parameters<RunCommandArgs>,
    ) -> Result<String, String> {
        self.session(&args.session)?;
        tmux::list_panes(&args.session, None)
            .await
            .map_err(one_line)?;
        let output = tmux::run_command(&args.session, &args.command, None)
            .await
            .map_err(one_line)?;
        let output = output.trim();
        if output.is_empty() {
            return Ok(format!("{} on {}: done", args.command, args.session));
        }
        Ok(format!("{} on {}: {output}", args.command, args.session))
    }

    #[tool(
        description = "Type text into the active pane of a session as literal keystrokes, optionally followed by Enter. A newline in the text runs the line, even with enter off."
    )]
    async fn send_keys(
        &self,
        Parameters(args): Parameters<SendKeysArgs>,
    ) -> Result<String, String> {
        self.session(&args.session)?;
        let enter = args.enter.unwrap_or(false);
        tmux::send_text(&args.session, &args.text, enter, None)
            .await
            .map_err(one_line)?;
        let typed = args.text.chars().count();
        Ok(match enter {
            true => format!("typed {typed} characters and Enter into {}", args.session),
            false => format!("typed {typed} characters into {}", args.session),
        })
    }

    #[tool(description = "Send a push notification to every phone subscribed to this mobux.")]
    async fn notify(&self, Parameters(args): Parameters<NotifyArgs>) -> Result<String, String> {
        if args.body.trim().is_empty() {
            return Err("body is required".to_string());
        }
        let outcome = self
            .push(push::Payload {
                title: args.title,
                body: args.body,
                tag: None,
                url: None,
            })
            .await?;
        Ok(format!("notification delivered: {outcome}"))
    }

    #[tool(
        description = "Push a notification that opens a page on the phone when tapped: an http(s) URL, or a path on this mobux such as /files/site/ or /proxy/vite/."
    )]
    async fn show_on_phone(
        &self,
        Parameters(args): Parameters<ShowOnPhoneArgs>,
    ) -> Result<String, String> {
        let target = link_target(&args.url)?;
        let outcome = self
            .push(push::Payload {
                title: args.title,
                body: target.clone(),
                tag: None,
                url: Some(target.clone()),
            })
            .await?;
        Ok(format!("{target} delivered: {outcome}"))
    }
}

#[tool_handler(router = self.tool_router)]
impl ServerHandler for Mobux {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
            .with_server_info(Implementation::new("mobux", env!("CARGO_PKG_VERSION")))
            .with_instructions(
                "mobux runs the tmux sessions on this host and shows them on the user's phone. \
                 Read and drive those sessions, and push a notification or a page to the phone.",
            )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use tower::ServiceExt;

    const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#;

    fn test_router() -> (Router, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let db = Arc::new(Db::open(&dir.path().join("mobux.db")).unwrap());
        let context = Context::new(
            Arc::new(Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap()),
            db,
            &config::Config::default(),
        );
        (router(context, CancellationToken::new()), dir)
    }

    async fn post(host: &str, origin: Option<&str>) -> (StatusCode, String) {
        let (router, _dir) = test_router();
        let mut request = Request::post(PATH)
            .header("host", host)
            .header("content-type", "application/json")
            .header("accept", "application/json, text/event-stream");
        if let Some(origin) = origin {
            request = request.header("origin", origin);
        }
        let response = router
            .oneshot(request.body(Body::from(INITIALIZE)).unwrap())
            .await
            .unwrap();
        let status = response.status();
        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
            .await
            .unwrap();
        (status, String::from_utf8_lossy(&body).into_owned())
    }

    #[tokio::test]
    async fn a_loopback_host_without_an_origin_is_served() {
        for host in ["127.0.0.1:8415", "localhost:8415", "[::1]:8415"] {
            let (status, _) = post(host, None).await;
            assert_eq!(status, StatusCode::OK, "{host}");
        }
    }

    #[tokio::test]
    async fn a_loopback_origin_is_served() {
        let (status, _) = post("127.0.0.1:8415", Some("http://localhost:3000")).await;
        assert_eq!(status, StatusCode::OK);
    }

    #[tokio::test]
    async fn a_non_loopback_host_is_refused_with_one_line() {
        for host in ["evil.example", "evil.example:8415", "192.168.1.5:8415"] {
            let (status, body) = post(host, None).await;
            assert_eq!(status, StatusCode::FORBIDDEN, "{host}");
            assert_eq!(body, "Forbidden: Host header is not allowed");
        }
    }

    #[tokio::test]
    async fn a_non_loopback_origin_is_refused_with_one_line() {
        for origin in [
            "http://evil.example",
            "https://127.0.0.1.evil.example",
            "null",
        ] {
            let (status, body) = post("127.0.0.1:8415", Some(origin)).await;
            assert_eq!(status, StatusCode::FORBIDDEN, "{origin}");
            assert_eq!(body, "Forbidden: Origin header is not allowed");
        }
    }

    #[tokio::test]
    async fn the_other_listeners_answer_404_at_the_mcp_path() {
        let response = absent::<()>()
            .oneshot(
                Request::post(PATH)
                    .header("host", "127.0.0.1")
                    .body(Body::from(INITIALIZE))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[test]
    fn a_path_is_sent_relative_to_the_instance_root() {
        assert_eq!(link_target("/files/site/").unwrap(), "files/site/");
        assert_eq!(link_target("proxy/vite/").unwrap(), "proxy/vite/");
        assert_eq!(link_target("//evil.com/x").unwrap(), "evil.com/x");
    }

    #[test]
    fn a_backslash_in_a_path_is_refused() {
        assert!(link_target(r"\\evil.com/x").is_err());
        assert!(link_target(r"\/evil.com").is_err());
        assert!(link_target(r"/files/a\b").is_err());
    }

    #[test]
    fn an_http_url_is_kept_and_other_schemes_are_refused() {
        assert_eq!(
            link_target("https://example.com/a").unwrap(),
            "https://example.com/a"
        );
        assert!(link_target("javascript:alert(1)").is_err());
        assert!(link_target("  ").is_err());
    }
}