From bd51750fba8ef533d2d823d5c4e0073ebda5fa76 Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Wed, 26 Aug 2026 15:39:13 -0400 Subject: [PATCH] docs: cut the README back to what a reader needs Assisted-By: Claude Opus 5 --- README.md | 353 ++++------------------------------------------------ src/eval.rs | 7 +- 2 files changed, 29 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index ca79148..d2ea4f0 100644 --- a/README.md +++ b/README.md @@ -1,379 +1,76 @@ # Erlang Distribution MCP Server A Model Context Protocol (MCP) server that connects to Erlang/BEAM nodes over the Erlang -distribution protocol and runs code on them. Five tools: connect, disconnect, list, evaluate -source, load a module. +distribution protocol and runs code on them. -There is no catalogue of introspection tools, because there does not need to be one. Process -state, ETS contents, supervision trees, memory, tracing — these are functions the node already -has, and `eval` reaches all of them. Anything the node can do, an assistant can ask for -directly. +Introspection, tracing and debugging are not tools here — they are functions the node already +has, and `eval` reaches all of them. -Unlike in-VM servers such as Tidewave, this runs as an external node. Nothing is added to your -application, no recompile is needed, and it detaches cleanly when you are done. +> **Development tool.** Code runs unsandboxed, with the full authority of the target node. +> The cookie is the only boundary. -> **This is a development tool.** Code runs with the full authority of the target node. -> See [Security](#security). - -## Features - -- **Evaluate anything**: Erlang or Elixir source, evaluated on the node, results returned as - structured JSON -- **Load modules at runtime**: compile source on the node and load it into the code server -- **Nothing to install first**: works against a stock node using only stdlib — no agent, no - recompile; the one small runner module is compiled and loaded for you on connect -- **Bounded**: evaluation runs in a process with a time and heap budget, and is killed on the - node if it overruns -- **Dynamic node connections**: connect to multiple nodes at runtime -- **Multi-language output**: format terms as Erlang, Elixir, Gleam, or LFE - -## Installation - -### From Source (Recommended) - -```bash -git clone https://github.com/jimsynz/erl_dist_mcp.git -cd erl_dist_mcp -cargo build --release -``` - -The binary will be at `target/release/erl_dist_mcp`. - -### Using Cargo Install +## Install ```bash +cargo build --release # from source, binary at target/release/erl_dist_mcp cargo install erl_dist_mcp ``` -### Pre-built Binaries - -Download the latest release for your platform from the [releases page](https://github.com/jimsynz/erl_dist_mcp/releases). - -Available targets: -- **Linux**: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` -- **macOS**: `x86_64-apple-darwin` (Intel), `aarch64-apple-darwin` (Apple Silicon) -- **Windows**: `x86_64-pc-windows-msvc` - -Extract the archive and move the binary to a directory in your PATH: - -```bash -# Linux/macOS -tar xzf erl_dist_mcp-*.tar.gz -sudo mv erl_dist_mcp /usr/local/bin/ - -# Windows (PowerShell) -Expand-Archive erl_dist_mcp-*.zip -Move-Item erl_dist_mcp.exe C:\Windows\System32\ -``` - -### Platform-Specific Notes - -**macOS**: On first run, you may see a security warning. Right-click the binary and select "Open" to bypass Gatekeeper, or run: - -```bash -xattr -d com.apple.quarantine /usr/local/bin/erl_dist_mcp -``` - -**Windows**: You may need to add the binary location to your PATH environment variable. - -**Linux (ARM)**: The `aarch64` build requires glibc 2.27 or later (Ubuntu 18.04+, Debian 10+). - -### Verification - -After installation, verify the binary works: - -```bash -erl_dist_mcp --version -``` - -You should see output like: -``` -erl_dist_mcp 0.1.0 -``` - -Test the help output: -```bash -erl_dist_mcp --help -``` -## Quick Start - -### 1. Start an Erlang node - -```bash -# Erlang -erl -sname test -setcookie mycookie - -# Elixir -iex --sname test --cookie mycookie -``` - -### 2. Configure Claude Desktop - -Add to your `claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "erlang": { - "command": "/path/to/erl_dist_mcp", - "args": ["--mode", "elixir"], - "env": {} - } - } -} -``` - -**Configuration file locations:** -- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json` -- Linux: `~/.config/Claude/claude_desktop_config.json` - -### 3. Use from Claude - -``` -Connect to my local Elixir node at test@localhost with cookie mycookie -``` - -``` -Which processes have the largest message queues? -``` - -``` -Load a module onto the node that summarises the supervision tree, then call it -``` - -## CLI Reference - -``` -erl_dist_mcp [OPTIONS] - -Options: - --mode Output format mode [default: erlang] - Values: erlang, elixir, gleam, lfe - - --log-level Set logging level [default: info] - Values: trace, debug, info, warn, error - - -h, --help Print help information - -V, --version Print version information -``` - -### Output Modes - -The `--mode` flag controls how Erlang terms are formatted: - -- **erlang**: Standard Erlang syntax (e.g., `{ok, Value}`, `#{key => value}`) -- **elixir**: Idiomatic Elixir syntax (e.g., `{:ok, value}`, `%{key: value}`) -- **gleam**: Gleam-like syntax (e.g., `Ok(value)`, `#(tuple, elements)`) -- **lfe**: Lisp Flavoured Erlang (e.g., `#(ok value)`, `#m(key value)`) - -The mode is fixed at startup. - -## Configuration for Different Editors - -### Claude Desktop - -See Quick Start section above. +Or download a binary from the [releases page](https://github.com/jimsynz/erl_dist_mcp/releases). -### Cursor +## Use -Add to your Cursor MCP settings (`.cursor/mcp.json` or Cursor settings): +Start a node, then point an MCP client at the binary: ```json { "mcpServers": { "erlang": { - "command": "/path/to/erl_dist_mcp", - "args": ["--mode", "elixir"], - "env": {} - } - } -} -``` - -### Continue.dev - -Add to `~/.continue/config.json`: - -```json -{ - "mcpServers": [ - { - "name": "erlang", "command": "/path/to/erl_dist_mcp", "args": ["--mode", "elixir"] } - ] -} -``` - -### Cline (VS Code) - -Add to VS Code settings under "Cline: MCP Servers": - -```json -{ - "erl_dist_mcp": { - "command": "/path/to/erl_dist_mcp", - "args": ["--mode", "elixir"] } } ``` -## Available Tools +`--mode` sets how terms are rendered: `erlang` (default), `elixir`, `gleam`, or `lfe`. +`--log-level` takes the usual five. Both are fixed at startup. + +## Tools | Tool | Description | |------|-------------| | `connect_node` | Connect to a node by name and cookie | | `disconnect_node` | Close a connection | | `list_nodes` | List connected nodes and their status | -| `eval` | Evaluate Erlang or Elixir source on a node, returning the result. Bounded by `timeout_ms` | -| `load_module` | Compile module source on a node and load it into the code server | - -### How `eval` works +| `eval` | Evaluate Erlang or Elixir source, bounded by `timeout_ms` (default 15s) | +| `load_module` | Compile module source on the node and load it | -For Erlang, the server chains three calls to the node's own stdlib and passes the intermediates -straight back through: +Nothing needs installing on the target: both tools drive the node's own scanner, parser, +evaluator and compiler. `connect_node` loads one small runner module, which bounds each +evaluation in time and memory and kills it on overrun. ``` -erl_scan:string -> erl_parse:parse_exprs -> erl_eval:exprs/2 +eval(node: "test@localhost", code: "erlang:process_info(whereis(user), [memory]).") +load_module(node: "test@localhost", source: "-module(h).\n-export([n/0]).\nn() -> node().") ``` -`erl_eval:exprs/2` takes no function handler, so nothing is filtered: `process_info`, `ets`, -`sys:get_state`, funs, and side effects all work. Elixir source routes through -`Code.eval_string` instead. - -Every evaluation runs inside a runner process the server owns, spawned with a time budget and a -`max_heap_size` limit. If the code overruns either, it is killed on the node — an RPC timeout -alone would only bound how long *we* wait, leaving the work running. `connect_node` loads that -runner (`erl_dist_mcp_runner`) onto the node, which is the one thing this server writes to a -node before you ask it to. - -`load_module` extends the same idea — split the token stream on each form, `erl_parse:parse_form` -each one, `compile:forms`, then hand the resulting beam binary back to `code:load_binary`. The -compile happens on the target node using its own compiler, so there is nothing to install and no -version skew. - -### Examples - -Inspect a process: - -``` -eval(node: "test@localhost", code: "erlang:process_info(whereis(user), [message_queue_len, memory]).") -``` - -Summarise ETS usage: - -``` -eval(node: "test@localhost", code: "lists:sum([ets:info(T, memory) || T <- ets:all()]).") -``` - -Load a helper and call it: - -``` -load_module(node: "test@localhost", source: "-module(dbg_helper).\n-export([top/1]).\ntop(N) -> lists:sublist(lists:reverse(lists:keysort(2, [{P, element(2, process_info(P, memory))} || P <- processes()])), N).") -eval(node: "test@localhost", code: "dbg_helper:top(5).") -``` - -## Security - -The Erlang distribution protocol has no security model below the cookie. Anyone holding the -cookie can already spawn arbitrary processes, load code, and stop the node. This server does not -pretend otherwise: `eval` and `load_module` are unsandboxed by design, because a whitelist would -only constrain the legitimate user while stopping no one else. - -What follows from that: - -- **Point this at development nodes.** Not production. -- **Treat the cookie as the boundary.** Long, random, not committed to version control, rotated. -- **Limit network access.** Firewall which hosts can reach the distribution port and EPMD. -- **Review what runs.** The assistant's code executes with the node's full authority. - -There is no `--allow-eval` flag. Evaluation is what this server is for, so gating it behind a -flag gated the whole tool. - -## Troubleshooting - -### "Node unreachable" or "EPMD lookup failed" - -Check that EPMD is running (`epmd -names`) and that the node is registered under the short name -you passed. Short names (`-sname`) and long names (`-name`) are not interchangeable. - -### "Authentication failed for node" - -The cookie does not match. Check `~/.erlang.cookie` on the target, or whatever `-setcookie` -passed. - -### Every call times out, with no error - -`rex` addresses its reply to the pid we send as `From`, so that pid must name the node we -actually handshook as. If they disagree, replies route to a node that does not exist and calls -time out silently. `rpc::local_pid()` derives this from the connection manager. - -### "parse failed" on valid-looking code - -Erlang expressions passed to `eval` must end with a period. Elixir source must not. - -### "evaluation killed on the node: timeout" - -The code ran longer than its budget and was killed. Raise it with `timeout_ms` on the `eval` -call, or do the work in a module loaded with `load_module`, which is compiled rather than -interpreted. - -### "could not load the erl_dist_mcp_runner module" - -Connecting loads a small runner module onto the node, and evaluation refuses to proceed without -it. This usually means the cookie authenticated but the node's code server rejected the load. +Erlang source passed to `eval` must end with a period; Elixir must not. ## Development ```bash -cargo build -cargo test -cargo clippy --all-targets --all-features -- -D warnings -cargo fmt --check -cargo run -- --mode elixir --log-level debug +cargo fmt && cargo clippy --all-targets --all-features -- -D warnings && cargo test ``` -The tests in `tests/eval_chain.rs` need a live Erlang node and are `#[ignore]`d by default. They -must run single-threaded: a node refuses concurrent handshakes from the same peer name. +The tests in `tests/eval_chain.rs` need a live node and must run single-threaded, since a node +refuses concurrent handshakes from the same peer name: ```bash setsid nohup erl -sname protonode -setcookie protocookie -noshell -noinput & cargo test --test eval_chain -- --ignored --test-threads=1 ``` -## Architecture - -- **`server.rs`**: the five MCP tools, and term-to-JSON rendering -- **`eval.rs`**: the scan/parse/eval and scan/parse/compile/load chains -- **`connection.rs`**: manages node connections; each runs a background tokio task that - multiplexes RPC over the distribution channel -- **`rpc.rs`**: calls to the `rex` process, plus term helpers and extractors -- **`formatter.rs`**: pluggable term formatters for Erlang, Elixir, Gleam, LFE -- **`error.rs`**: connection, RPC, and tool errors - -Key design decisions: -- **Terms stay terms**: tokens, ASTs, and beam binaries pass back through the server as - `eetf::Term` and are never converted to JSON, so nothing is lost in transit -- **Runtime-agnostic erl_dist**: uses futures_io traits, works with tokio via an adapter -- **Background connection tasks**: each node connection runs in its own tokio task -- **Trait-based formatting**: pluggable formatters for different BEAM languages - -## Contributing - -Contributions are welcome! Please: - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes with tests -4. Ensure all tests pass (`cargo test`) -5. Ensure clippy is happy (`cargo clippy --all-targets --all-features -- -D warnings`) -6. Format your code (`cargo fmt`) -7. Commit your changes (`git commit -m 'feat: add amazing feature'`) -8. Push to the branch (`git push origin feature/amazing-feature`) -9. Open a Pull Request - ## License This project is licensed under the Apache License, Version 2.0 — see the [LICENSE](LICENSE) file for details. diff --git a/src/eval.rs b/src/eval.rs index 797c5cc..3b4d9b9 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -43,9 +43,10 @@ pub const RUNNER_MODULE: &str = "erl_dist_mcp_runner"; /// Source for [`RUNNER_MODULE`]. /// -/// Nothing in `eval` is bounded from our side: the RPC timeout bounds how long -/// *we* wait, not how long the node works. This runs the call in a process we -/// own, so a runaway expression is killed rather than left behind. +/// An RPC timeout bounds how long *we* wait, not how long the node works, and +/// stdlib will not do the bounding for us: neither `rpc:call/5` nor +/// `erpc:call/5` kills its worker when the timeout expires, so both leave the +/// runaway process running. Hence a process we spawn, monitor, and kill. const RUNNER_SOURCE: &str = r#" -module(erl_dist_mcp_runner). -export([run/4]). -- 2.51.2