From b4cf2652623d11783500a39b609ca80fbb41f446 Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Thu, 27 Aug 2026 13:58:33 -0400 Subject: [PATCH] feat: connect to a node at startup from the command line Assisted-By: Claude Opus 5 --- README.md | 5 ++++ src/cookie.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 ++ src/main.rs | 43 +++++++++++++++++++++++++++-- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/cookie.rs diff --git a/README.md b/README.md index 635e6e8..cb62310 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ Start a node, then point an MCP client at the binary: `--mode` sets how terms are rendered: `erlang` (default), `elixir`, `gleam`, or `lfe`. `--log-level` takes the usual five. Both are fixed at startup. +`--node myapp@localhost` connects at startup, reading the cookie from `--cookie-file` +(default `.erlang.cookie`, relative to the working directory the client launches the server +in). If the node is down or the cookie unreadable the server still starts and logs a warning, +leaving `connect_node` to pick things up later. + ## Tools | Tool | Description | diff --git a/src/cookie.rs b/src/cookie.rs new file mode 100644 index 0000000..888078f --- /dev/null +++ b/src/cookie.rs @@ -0,0 +1,75 @@ +//! Reading the Erlang cookie from a file. + +use std::io; +use std::path::{Path, PathBuf}; + +/// The cookie file autoconnect reads when none is named on the command line. +pub const DEFAULT_COOKIE_FILE: &str = ".erlang.cookie"; + +/// Returns the default cookie path, relative to the current directory. +pub fn default_cookie_path() -> PathBuf { + PathBuf::from(DEFAULT_COOKIE_FILE) +} + +/// Reads a cookie from `path`, trimming the surrounding whitespace Erlang's own +/// cookie files carry. +/// +/// # Errors +/// Returns an error if the file cannot be read, or holds nothing but whitespace. +pub fn read_cookie(path: &Path) -> io::Result { + let contents = std::fs::read_to_string(path)?; + let cookie = contents.trim(); + + if cookie.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("cookie file '{}' is empty", path.display()), + )); + } + + Ok(cookie.to_string()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use std::io::Write; + + fn cookie_file(contents: &str) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + file.flush().unwrap(); + file + } + + #[test] + fn reads_a_cookie() { + let file = cookie_file("SECRETCOOKIE"); + assert_eq!(read_cookie(file.path()).unwrap(), "SECRETCOOKIE"); + } + + #[test] + fn trims_surrounding_whitespace() { + let file = cookie_file(" SECRETCOOKIE\n"); + assert_eq!(read_cookie(file.path()).unwrap(), "SECRETCOOKIE"); + } + + #[test] + fn rejects_a_whitespace_only_cookie() { + let file = cookie_file("\n \n"); + let err = read_cookie(file.path()).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn reports_a_missing_file() { + let err = read_cookie(Path::new("/nonexistent/.erlang.cookie")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn defaults_to_the_working_directory() { + assert_eq!(default_cookie_path(), Path::new(".erlang.cookie")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9fbad9f..d814e6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! module. Everything else is reachable from there. pub mod connection; +pub mod cookie; pub mod error; pub mod eval; pub mod formatter; @@ -14,6 +15,7 @@ pub mod server; pub use connection::{ CallRef, ConnectionManager, ConnectionState, NodeConnection, NodeStatus, RequestSender, }; +pub use cookie::{DEFAULT_COOKIE_FILE, default_cookie_path, read_cookie}; pub use error::{ConnectionError, ConnectionResult, RpcError, RpcResult, ToolError, ToolResult}; pub use eval::{Language, LoadedModule}; pub use formatter::{TermFormatter, get_formatter}; diff --git a/src/main.rs b/src/main.rs index ef4c348..d990e97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,10 @@ //! This binary starts the MCP server with stdio transport. use clap::Parser; -use erl_dist_mcp::{ErlDistMcpServer, FormatterMode}; +use erl_dist_mcp::{DEFAULT_COOKIE_FILE, ErlDistMcpServer, FormatterMode, read_cookie}; use rmcp::ServiceExt; use rmcp::transport::stdio; +use std::path::{Path, PathBuf}; use tracing_subscriber::EnvFilter; /// Erlang Distribution MCP Server @@ -23,6 +24,14 @@ struct Args { /// Log level (trace, debug, info, warn, error). #[arg(long, default_value = "info")] log_level: String, + + /// Node to connect to at startup, e.g. "myapp@localhost". + #[arg(long)] + node: Option, + + /// File holding the Erlang cookie, read when --node is given. + #[arg(long, default_value = DEFAULT_COOKIE_FILE)] + cookie_file: PathBuf, } fn parse_mode(s: &str) -> Result { @@ -42,8 +51,38 @@ async fn main() -> Result<(), Box> { tracing::info!("Starting erl_dist_mcp server (mode: {})", args.mode); - let service = ErlDistMcpServer::new(args.mode).serve(stdio()).await?; + let server = ErlDistMcpServer::new(args.mode); + + if let Some(node) = &args.node { + autoconnect(&server, node, &args.cookie_file).await; + } + + let service = server.serve(stdio()).await?; service.waiting().await?; Ok(()) } + +/// Connects to `node` before the server starts serving. +/// +/// A failure here is logged rather than fatal: the node is often still starting +/// when the client launches us, and `connect_node` remains available. +async fn autoconnect(server: &ErlDistMcpServer, node: &str, cookie_file: &Path) { + let cookie = match read_cookie(cookie_file) { + Ok(cookie) => cookie, + Err(e) => { + tracing::warn!("Cannot read cookie from {}: {e}", cookie_file.display()); + return; + } + }; + + match server + .state() + .connection_manager + .connect(node, &cookie) + .await + { + Ok(()) => tracing::info!("Connected to {node}"), + Err(e) => tracing::warn!("Cannot connect to {node}: {e}"), + } +} -- 2.51.2