use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use crate::App;
pub const DEFAULT_MCP_PORT: u16 = 7381;
#[derive(Clone, PartialEq, Eq)]
pub struct McpEndpoint {
pub addr: SocketAddr,
pub token: String,
}
impl std::fmt::Debug for McpEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpEndpoint")
.field("addr", &self.addr)
.finish_non_exhaustive()
}
}
pub trait McpHttpServer {
fn serve(&mut self, endpoint: Option<McpEndpoint>) -> Result<Option<SocketAddr>, String>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpHttpState {
Unavailable,
Off,
Serving(SocketAddr),
Failed(String),
}
pub fn default_token_path() -> Option<PathBuf> {
crate::Preferences::default_path().map(|p| p.with_file_name("mcp-token"))
}
pub fn load_or_create_token(path: &Path) -> std::io::Result<String> {
match std::fs::read_to_string(path) {
Ok(token) if !token.trim().is_empty() => Ok(token.trim().to_owned()),
Ok(_) => new_token(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => new_token(path),
Err(e) => Err(e),
}
}
pub fn new_token(path: &Path) -> std::io::Result<String> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut bytes = [0u8; 32];
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
file.write_all(token.as_bytes())?;
file.write_all(b"\n")?;
drop(file);
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
std::fs::rename(&tmp, path)?;
Ok(token)
}
pub fn tokens_match(given: &[u8], token: &[u8]) -> bool {
given.len() == token.len() && given.iter().zip(token).fold(0, |d, (a, b)| d | (a ^ b)) == 0
}
pub(crate) fn tilde(path: &Path) -> String {
let home = std::env::var_os("HOME").map(PathBuf::from);
match home
.as_deref()
.and_then(|home| path.strip_prefix(home).ok())
{
Some(rest) => Path::new("~").join(rest).display().to_string(),
None => path.display().to_string(),
}
}
pub fn mcp_url(addr: SocketAddr) -> String {
format!("http://{addr}/mcp")
}
impl App {
pub fn set_mcp_http_server(&mut self, server: Box<dyn McpHttpServer>, token_path: PathBuf) {
self.mcp_http = Some(server);
self.mcp_token_path = Some(token_path);
self.apply_mcp_http();
}
pub fn mcp_http_state(&self) -> &McpHttpState {
&self.mcp_http_state
}
pub(crate) fn apply_mcp_http(&mut self) {
let Some(server) = self.mcp_http.as_mut() else {
self.mcp_http_state = McpHttpState::Unavailable;
return;
};
let endpoint = match (&self.mcp_token_path, self.prefs.mcp_http) {
(_, false) => None,
(None, true) => {
self.mcp_http_state = McpHttpState::Failed("no place for a token".into());
return;
}
(Some(path), true) => match load_or_create_token(path) {
Ok(token) => Some(McpEndpoint {
addr: (Ipv4Addr::LOCALHOST, self.prefs.mcp_http_port).into(),
token,
}),
Err(e) => {
let _ = server.serve(None);
self.mcp_http_state =
McpHttpState::Failed(format!("reading {}: {e}", path.display()));
return;
}
},
};
self.mcp_http_state = match server.serve(endpoint) {
Ok(Some(addr)) => McpHttpState::Serving(addr),
Ok(None) => McpHttpState::Off,
Err(e) => McpHttpState::Failed(e),
};
}
pub(crate) fn renew_mcp_token(&mut self) -> Result<(), String> {
let path = self
.mcp_token_path
.clone()
.ok_or("this instance doesn't serve MCP over HTTP")?;
new_token(&path).map_err(|e| format!("writing {}: {e}", path.display()))?;
self.apply_mcp_http();
self.message = Some("Made a new MCP token".into());
Ok(())
}
pub(crate) fn mcp_token_path(&self) -> Option<&Path> {
self.mcp_token_path.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_are_owner_only_and_kept() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dawkey/mcp-token");
let token = load_or_create_token(&path).unwrap();
assert_eq!(token.len(), 64);
assert!(token.bytes().all(|b| b.is_ascii_hexdigit()));
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
assert_eq!(load_or_create_token(&path).unwrap(), token);
let renewed = new_token(&path).unwrap();
assert_ne!(renewed, token);
assert_eq!(load_or_create_token(&path).unwrap(), renewed);
}
#[test]
fn tokens_match_only_exactly() {
assert!(tokens_match(b"abc", b"abc"));
assert!(!tokens_match(b"abd", b"abc"));
assert!(!tokens_match(b"ab", b"abc"));
assert!(!tokens_match(b"", b"abc"));
}
}