git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit7c85aac7512eea03df00941e0b15db648046f046
parent60bdc9765f
authorLucas Galante <[email protected]>
date2026-07-16 15:12
refactor: retire the HTTP API; MCP is the only automation surface

The bespoke HTTP server (port 3000, GET /state + POST /action,
CCE_DESIGNER_HTTP_PORT) is removed; the MCP server on 3001 already
speaks the same action surface with discoverable, schema'd tools.
HttpAction is renamed McpAction and the GetState/PostAction events are
gone. The app was also its own HTTP client: the cce-files chooser
threads delivered the picked path by POSTing load/save to port 3000 —
they now send a fire-and-forget CustomEvent::RunAction over the engine
calloop sender stored in State::event_sender.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md          |  30 +++++------
 src/api.rs         | 146 +++--------------------------------------------------
 src/app.rs         |  60 ++++++----------------
 src/application.rs |   8 +--
 src/main.rs        |  18 +++----
 src/window.rs      |  70 +++++++++++++------------
 6 files changed, 85 insertions(+), 247 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 51c26a2..693fdd4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,23 +39,21 @@ kernels and need a working OpenCL runtime; they are not pure-CPU tests.
   with the main window by autosaving/polling `default_project.json` mtime (see the
   main loop in `src/main.rs`) — there is no socket between the two.
 
-### HTTP automation API + MCP server
-
-The main window runs an embedded HTTP server on `127.0.0.1:3000` (`src/api.rs`):
-`GET /state` returns the app state as JSON; `POST /action` takes an `HttpAction`
-JSON body (`add_node`, `set_param`, `menu_action`, `save`, `load`, …— see the enum
-in `src/app.rs`). This is the main way to drive/inspect the running app when
-debugging: `curl -X POST localhost:3000/action -d '{"action":"add_node","template_name":"Sphere","x":5,"y":3}'`.
-
-The same surface is exposed as an MCP server on `127.0.0.1:3001`
-(`CCE_DESIGNER_MCP_PORT` overrides; attach with
-`claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`):
-one tool per `HttpAction` variant (tool name = the variant's serde tag,
-dispatched in `apply_mcp_call` in `src/window.rs`) plus `get_state`. The tool
-list lives in `mcp_tools()` in `src/api.rs`; the protocol layer is
+### MCP automation server
+
+The main window runs an embedded MCP server on `127.0.0.1:3001`
+(`CCE_DESIGNER_MCP_PORT` overrides so a second instance can run alongside;
+`src/api.rs`). This is the way to drive/inspect the running app: attach with
+`claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`, or
+speak JSON-RPC directly with curl (`initialize` / `tools/list` / `tools/call`).
+There is one tool per `McpAction` variant (tool name = the variant's serde
+tag, dispatched in `apply_mcp_call` in `src/window.rs`) plus `get_state`. The
+tool list lives in `mcp_tools()` in `src/api.rs`; the protocol layer is
 `cce_ui::mcp` (tools-only Streamable HTTP). Keep the enum, the tool list, and
-the schemas in sync — `test_mcp_tools_map_to_http_actions` enforces the
-mapping.
+the schemas in sync — `test_mcp_tools_map_to_actions` enforces the mapping.
+(The former bespoke HTTP API on port 3000 was retired in favor of this;
+app-internal threads like the cce-files choosers now return results via
+`CustomEvent::RunAction` instead of POSTing to it.)
 
 ## Architecture
 
diff --git a/src/api.rs b/src/api.rs
index d08c2a2..c574359 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -1,146 +1,16 @@
-use std::net::TcpListener;
-use std::io::{BufRead, BufReader, Read, Write};
+//! The embedded MCP automation server — the way to drive/inspect the
+//! running app (the former bespoke HTTP API was retired in its favor).
+
 use cce_ui::mcp::McpTool;
 use serde_json::json;
-use crate::{CustomEvent, HttpAction};
-
-pub fn start_http_server(server_sender: calloop::channel::Sender<CustomEvent>) {
-    std::thread::spawn(move || {
-        // CCE_DESIGNER_HTTP_PORT overrides the default so a second instance
-        // (tests, debugging) can run alongside one already holding 3000.
-        let port: u16 = std::env::var("CCE_DESIGNER_HTTP_PORT")
-            .ok()
-            .and_then(|p| p.parse().ok())
-            .unwrap_or(3000);
-        let listener = match TcpListener::bind(("127.0.0.1", port)) {
-            Ok(l) => l,
-            Err(e) => {
-                eprintln!("Failed to bind HTTP server to port {port}: {:?}", e);
-                return;
-            }
-        };
-        println!("Embedded HTTP Server listening on http://127.0.0.1:{port}");
-
-        for stream in listener.incoming() {
-            let stream = match stream {
-                Ok(s) => s,
-                Err(_) => continue,
-            };
-
-            let server_sender = server_sender.clone();
-            std::thread::spawn(move || {
-                let mut write_stream = match stream.try_clone() {
-                    Ok(s) => s,
-                    Err(_) => return,
-                };
-                let mut reader = BufReader::new(stream);
-                let mut request_line = String::new();
-                if reader.read_line(&mut request_line).is_err() {
-                    return;
-                }
-
-                if request_line.starts_with("GET /state") {
-                    let (tx, rx) = std::sync::mpsc::channel();
-                    if server_sender.send(CustomEvent::GetState(tx)).is_ok() {
-                        let response_body = rx.recv().unwrap_or_else(|_| "null".to_string());
-                        let response = format!(
-                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                            response_body.len(),
-                            response_body
-                        );
-                        let _ = write_stream.write_all(response.as_bytes());
-                    }
-                } else if request_line.starts_with("POST /action") {
-                    let mut content_length = 0;
-                    loop {
-                        let mut header_line = String::new();
-                        if reader.read_line(&mut header_line).is_err() || header_line == "\r\n" || header_line == "\n" || header_line.is_empty() {
-                            break;
-                        }
-                        let lower = header_line.to_lowercase();
-                        if lower.starts_with("content-length:") {
-                            if let Some(val) = lower.split(':').nth(1) {
-                                if let Ok(len) = val.trim().parse::<usize>() {
-                                    content_length = len;
-                                }
-                            }
-                        }
-                    }
-
-                    let mut body = vec![0; content_length];
-                    if reader.read_exact(&mut body).is_ok() {
-                        let body_str = String::from_utf8_lossy(&body);
-                        if let Ok(action) = serde_json::from_str::<HttpAction>(&body_str) {
-                            let (tx, rx) = std::sync::mpsc::channel();
-                            if server_sender.send(CustomEvent::PostAction(action, tx)).is_ok() {
-                                let res = rx.recv().unwrap_or_else(|_| Err("internal error".to_string()));
-                                let response = match res {
-                                    Ok(msg) => {
-                                        let body = format!("{{\"status\":\"success\",\"message\":\"{}\"}}", msg);
-                                        format!(
-                                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                                            body.len(),
-                                            body
-                                        )
-                                    }
-                                    Err(err) => {
-                                        let body = format!("{{\"status\":\"error\",\"error\":\"{}\"}}", err);
-                                        format!(
-                                            "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                                            body.len(),
-                                            body
-                                        )
-                                    }
-                                };
-                                let _ = write_stream.write_all(response.as_bytes());
-                            } else {
-                                let body = "{\"status\":\"error\",\"error\":\"failed to send action to event loop\"}";
-                                let response = format!(
-                                    "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                                    body.len(),
-                                    body
-                                );
-                                let _ = write_stream.write_all(response.as_bytes());
-                            }
-                        } else {
-                            let body = "{\"status\":\"error\",\"error\":\"failed to parse action JSON\"}";
-                            let response = format!(
-                                "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                                body.len(),
-                                body
-                            );
-                            let _ = write_stream.write_all(response.as_bytes());
-                        }
-                    } else {
-                        let body = "{\"status\":\"error\",\"error\":\"failed to read complete body\"}";
-                        let response = format!(
-                            "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                            body.len(),
-                            body
-                        );
-                        let _ = write_stream.write_all(response.as_bytes());
-                    }
-                } else {
-                    let body = "{\"error\":\"not found\"}";
-                    let response = format!(
-                        "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
-                        body.len(),
-                        body
-                    );
-                    let _ = write_stream.write_all(response.as_bytes());
-                }
-                let _ = write_stream.flush();
-            });
-        }
-    });
-}
+use crate::CustomEvent;
 
 /// Start the embedded MCP server (cce-ui's tools-only Streamable HTTP
-/// implementation): the same automation surface as the HTTP API, spoken as
-/// MCP tools so agents can attach with
+/// implementation). Agents attach with
 /// `claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`.
 pub fn start_mcp_server(server_sender: calloop::channel::Sender<CustomEvent>) {
-    // CCE_DESIGNER_MCP_PORT overrides the default, same as the HTTP port.
+    // CCE_DESIGNER_MCP_PORT overrides the default so a second instance
+    // (tests, debugging) can run alongside one already holding 3001.
     let port: u16 = std::env::var("CCE_DESIGNER_MCP_PORT")
         .ok()
         .and_then(|p| p.parse().ok())
@@ -148,7 +18,7 @@ pub fn start_mcp_server(server_sender: calloop::channel::Sender<CustomEvent>) {
     cce_ui::mcp::start_mcp_server("cce-designer", port, mcp_tools(), server_sender, CustomEvent::McpCall);
 }
 
-/// The designer's MCP tools: `get_state` plus one tool per `HttpAction`
+/// The designer's MCP tools: `get_state` plus one tool per `McpAction`
 /// variant — the tool name is the variant's serde tag and the arguments are
 /// its fields, so dispatch is deserialization (see `apply_mcp_call`).
 pub(crate) fn mcp_tools() -> Vec<McpTool> {
diff --git a/src/app.rs b/src/app.rs
index cea9892..267a0eb 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -318,7 +318,7 @@ pub struct Project {
 
 #[derive(Debug, Clone, Deserialize, Serialize)]
 #[serde(tag = "action", rename_all = "snake_case")]
-pub enum HttpAction {
+pub enum McpAction {
     Up,
     Enter { slot: usize },
     SetParam { slot: usize, name: String, value: String },
@@ -342,12 +342,13 @@ pub enum HttpAction {
 
 #[derive(Debug, Clone)]
 pub enum CustomEvent {
-    GetState(std::sync::mpsc::Sender<String>),
-    PostAction(HttpAction, std::sync::mpsc::Sender<Result<String, String>>),
     /// An MCP `tools/call` from the embedded MCP server (carries its own
-    /// reply channel) — the tool name is an `HttpAction` tag, or `get_state`.
+    /// reply channel) — the tool name is an `McpAction` tag, or `get_state`.
     McpCall(cce_ui::mcp::McpToolCall),
-    /// App-requested exit (menu File > Exit, HTTP menu_action): the engine's
+    /// A fire-and-forget action from an app-internal thread (the cce-files
+    /// choosers deliver their picked path this way).
+    RunAction(McpAction),
+    /// App-requested exit (menu File > Exit, MCP menu_action): the engine's
     /// update hook is the only place with exit access, so input handlers that
     /// see `exit_requested` route it here.
     Exit,
@@ -922,6 +923,10 @@ pub struct State {
     pub shortcut_manager: ShortcutManager,
     pub pending_action: Option<Action>,
     pub exit_requested: bool,
+    /// Engine event-loop sender so app-spawned threads (the cce-files
+    /// choosers) can deliver results back as `CustomEvent`s; set once by
+    /// `Application::new`.
+    pub event_sender: Option<calloop::channel::Sender<CustomEvent>>,
 
     pub slots: Box<WidgetSlots>,
     pub positions: Vec<(f32, f32, f32, f32)>,
@@ -1417,7 +1422,7 @@ impl State {
 
     /// One label-matched menu action: the button-param menu pane's items, drained
     /// per triggered button by `sync_parameters_to_project`, and reachable directly
-    /// through the HTTP API's `menu_action` (the index-matched `menu_click` cannot
+    /// through the `menu_action` tool (the index-matched `menu_click` cannot
     /// reach these). Returns false for an unrecognized label.
     pub fn execute_menu_action(&mut self, label: &str) -> bool {
         match label {
@@ -1724,9 +1729,9 @@ impl State {
     }
 
     pub fn open_file_chooser(&self) {
+        let Some(sender) = self.event_sender.clone() else { return };
         std::thread::spawn(move || {
             use std::process::{Command, Stdio};
-            use std::io::Write;
 
             // Find executable path
             let exe_path = if let Ok(cur_exe) = std::env::current_exe() {
@@ -1783,33 +1788,16 @@ impl State {
             if output.status.success() {
                 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
                 if !selected.is_empty() {
-                    let body = format!(
-                        "{{\"action\":\"load\",\"path\":\"{}\"}}",
-                        selected.replace('\\', "\\\\").replace('"', "\\\"")
-                    );
-                    let req = format!(
-                        "POST /action HTTP/1.1\r\n\
-                         Host: 127.0.0.1:3000\r\n\
-                         Content-Type: application/json\r\n\
-                         Content-Length: {}\r\n\
-                         Connection: close\r\n\r\n\
-                         {}",
-                        body.len(),
-                        body
-                    );
-                    if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
-                        let _ = stream.write_all(req.as_bytes());
-                        let _ = stream.flush();
-                    }
+                    let _ = sender.send(CustomEvent::RunAction(McpAction::Load { path: selected }));
                 }
             }
         });
     }
 
     pub fn save_file_chooser(&self) {
+        let Some(sender) = self.event_sender.clone() else { return };
         std::thread::spawn(move || {
             use std::process::{Command, Stdio};
-            use std::io::Write;
 
             // Find executable path
             let exe_path = if let Ok(cur_exe) = std::env::current_exe() {
@@ -1866,24 +1854,7 @@ impl State {
             if output.status.success() {
                 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
                 if !selected.is_empty() {
-                    let body = format!(
-                        "{{\"action\":\"save\",\"path\":\"{}\"}}",
-                        selected.replace('\\', "\\\\").replace('"', "\\\"")
-                    );
-                    let req = format!(
-                        "POST /action HTTP/1.1\r\n\
-                         Host: 127.0.0.1:3000\r\n\
-                         Content-Type: application/json\r\n\
-                         Content-Length: {}\r\n\
-                         Connection: close\r\n\r\n\
-                         {}",
-                        body.len(),
-                        body
-                    );
-                    if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
-                        let _ = stream.write_all(req.as_bytes());
-                        let _ = stream.flush();
-                    }
+                    let _ = sender.send(CustomEvent::RunAction(McpAction::Save { path: selected }));
                 }
             }
         });
@@ -2491,6 +2462,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             shortcut_manager,
             pending_action: None,
             exit_requested: false,
+            event_sender: None,
             slots,
             positions,
             splitter_layout,
diff --git a/src/application.rs b/src/application.rs
index ef7e995..892df41 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -15,7 +15,7 @@ use cce_ui::vk::VkRenderer;
 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 use wayland_client::QueueHandle;
 
-use crate::api::{start_http_server, start_mcp_server};
+use crate::api::start_mcp_server;
 use crate::app::{CustomEvent, PendingWindowDrag, State, TouchPhase, LEFT_MENUBAR_IDX};
 use crate::window::{LocalPosition, WindowEvent};
 
@@ -146,9 +146,9 @@ impl Application for State {
         sender: calloop::channel::Sender<CustomEvent>,
     ) -> Self {
         let is_detached_network = std::env::args().any(|arg| arg == "--detached-network");
-        let state = State::new(is_detached_network);
+        let mut state = State::new(is_detached_network);
+        state.event_sender = Some(sender.clone());
         if !is_detached_network {
-            start_http_server(sender.clone());
             start_mcp_server(sender);
         }
         state
@@ -179,7 +179,7 @@ impl Application for State {
         if self.apply_custom_event(msg) {
             *needs_rebuild = true;
         }
-        // HTTP can reach File > Exit through menu_action.
+        // MCP can reach File > Exit through menu_action.
         if self.exit_requested {
             self.autosave_on_exit();
             *exit = true;
diff --git a/src/main.rs b/src/main.rs
index c15d841..1656190 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,7 @@ pub mod application;
 
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
-use app::{CustomEvent, HttpAction, ModifiersState};
+use app::{CustomEvent, McpAction, ModifiersState};
 pub mod viewport_3d;
 pub mod api;
 pub mod window;
@@ -20,7 +20,7 @@ mod test_prelude {
     pub use std::path::Path;
     pub use glam::{Mat4, Vec3};
     pub use cce_ui::widget::{Key, NamedKey};
-    pub use crate::app::{State, HttpAction, ModifiersState};
+    pub use crate::app::{State, McpAction, ModifiersState};
 }
 
 fn main() {
@@ -452,11 +452,11 @@ mod tests {
     }
 
     #[test]
-    fn test_http_action_parsing() {
+    fn test_mcp_action_parsing() {
         let json_str = "{\"action\": \"add_node\", \"template_name\": \"Sphere\", \"name\": \"MySphere\", \"x\": 5.0, \"y\": 3.0}";
-        let action: HttpAction = serde_json::from_str(json_str).unwrap();
+        let action: McpAction = serde_json::from_str(json_str).unwrap();
         match action {
-            HttpAction::AddNode { template_name, name, x, y } => {
+            McpAction::AddNode { template_name, name, x, y } => {
                 assert_eq!(template_name, "Sphere");
                 assert_eq!(name, Some("MySphere".to_string()));
                 assert_eq!(x, 5.0);
@@ -742,9 +742,9 @@ mod tests {
     }
 
     #[test]
-    fn test_mcp_tools_map_to_http_actions() {
+    fn test_mcp_tools_map_to_actions() {
         // Every MCP tool except get_state must dispatch by injecting its name
-        // as the HttpAction serde tag; filling each schema property with a
+        // as the McpAction serde tag; filling each schema property with a
         // dummy of its declared type must yield a deserializable action, so
         // this catches tool-name/field drift against the enum.
         let tools = crate::api::mcp_tools();
@@ -770,8 +770,8 @@ mod tests {
                 }
             }
             args.insert("action".to_string(), serde_json::json!(tool.name));
-            serde_json::from_value::<HttpAction>(serde_json::Value::Object(args))
-                .unwrap_or_else(|e| panic!("tool '{}' does not map to an HttpAction: {e}", tool.name));
+            serde_json::from_value::<McpAction>(serde_json::Value::Object(args))
+                .unwrap_or_else(|e| panic!("tool '{}' does not map to an McpAction: {e}", tool.name));
         }
     }
 }
diff --git a/src/window.rs b/src/window.rs
index 2caafec..197dd87 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -4,13 +4,13 @@
 //! lives in cce-ui's window runner; the `Application` impl (application.rs)
 //! translates the runner's hooks into [`WindowEvent`]s. What remains here is
 //! app policy: the post-event side-effect pass (`process_window_event`) and
-//! HTTP-action application (`apply_custom_event`).
+//! MCP-action application (`apply_custom_event`).
 
 use std::path::Path;
 
 use cce_ui::widget::WidgetHost;
 use crate::shortcut::Action;
-use crate::app::{State, CustomEvent, HttpAction, TouchPhase, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
+use crate::app::{State, CustomEvent, McpAction, TouchPhase, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
 
 #[derive(Debug, Clone, Copy)]
 pub struct LocalPosition {
@@ -568,25 +568,22 @@ impl State {
         result
     }
 
-    /// Apply an HTTP/API event (the engine `update` hook). Returns true when
-    /// a redraw is needed.
+    /// Apply an automation event (the engine `update` hook). Returns true
+    /// when a redraw is needed.
     pub(crate) fn apply_custom_event(&mut self, event: CustomEvent) -> bool {
         let mut needs_redraw = false;
         {
             let state = &mut *self;
             match event {
-                CustomEvent::GetState(tx) => {
-                    let json = serde_json::to_string_pretty(&state.project_snapshot()).unwrap_or_default();
-                    let _ = tx.send(json);
-                }
-                CustomEvent::PostAction(action, tx) => {
-                    let res = state.apply_http_action(action, &mut needs_redraw);
-                    let _ = tx.send(res);
-                }
                 CustomEvent::McpCall(call) => {
                     let res = state.apply_mcp_call(&call, &mut needs_redraw);
                     let _ = call.reply.send(res);
                 }
+                CustomEvent::RunAction(action) => {
+                    if let Err(e) = state.apply_action(action, &mut needs_redraw) {
+                        eprintln!("Action failed: {e}");
+                    }
+                }
                 // Exit is handled by the Application::update wrapper
                 // (autosave + engine exit) before this is reached.
                 CustomEvent::Exit => {}
@@ -616,7 +613,7 @@ impl State {
     }
 
     /// An MCP tool call: `get_state` returns the project snapshot; every
-    /// other tool name is an `HttpAction` tag — injected into the arguments
+    /// other tool name is an `McpAction` tag — injected into the arguments
     /// and run through the shared action path.
     fn apply_mcp_call(
         &mut self,
@@ -633,24 +630,25 @@ impl State {
             serde_json::json!({})
         };
         req["action"] = serde_json::Value::String(call.name.clone());
-        match serde_json::from_value::<HttpAction>(req) {
+        match serde_json::from_value::<McpAction>(req) {
             Ok(action) => self
-                .apply_http_action(action, needs_redraw)
+                .apply_action(action, needs_redraw)
                 .map(serde_json::Value::String),
             Err(e) => Err(format!("invalid arguments for '{}': {e}", call.name)),
         }
     }
 
-    /// Apply one automation action (HTTP `POST /action` or an MCP tool call).
-    pub(crate) fn apply_http_action(
+    /// Apply one automation action (an MCP tool call, or an app-internal
+    /// fire-and-forget `RunAction`).
+    pub(crate) fn apply_action(
         &mut self,
-        action: HttpAction,
+        action: McpAction,
         redraw: &mut bool,
     ) -> Result<String, String> {
         let mut needs_redraw = false;
         let state = self;
         let res = match action {
-            HttpAction::Up => {
+            McpAction::Up => {
                 if state.move_up() {
                     needs_redraw = true;
                     Ok("Moved up".to_string())
@@ -658,7 +656,7 @@ impl State {
                     Err("Already at root".to_string())
                 }
             }
-            HttpAction::Enter { slot } => {
+            McpAction::Enter { slot } => {
                 let dir = state.current_dir();
                 if slot < dir.children.len() && (dir.children[slot].node_type == "node" || dir.children[slot].node_type == "utility" || !dir.children[slot].children.is_empty()) {
                     state.current_path.push(slot);
@@ -669,14 +667,14 @@ impl State {
                     Err("Not a valid subnet".to_string())
                 }
             }
-            HttpAction::SetParam { slot, name, value } => {
+            McpAction::SetParam { slot, name, value } => {
                 let dir = state.current_dir_mut();
                 if let Some(child) = dir.children.get_mut(slot) {
                     if let Some(p) = child.params.iter_mut().find(|p| p.name == name) {
                         p.default = value;
                         // Same sequence as the interactive param-pane
                         // path, so settings params (viewport flags,
-                        // grid) actually take effect over HTTP.
+                        // grid) actually take effect via automation.
                         state.apply_settings_from_menubar_subnets();
                         state.sync_grid_settings();
                         state.sync_nodes();
@@ -690,7 +688,7 @@ impl State {
                     Err("Slot index out of bounds".to_string())
                 }
             }
-            HttpAction::ResetCamera => {
+            McpAction::ResetCamera => {
                 if state.active_camera != "Default Camera" {
                     state.update_active_camera_rotation_reset();
                 } else {
@@ -702,7 +700,7 @@ impl State {
                 needs_redraw = true;
                 Ok("Camera reset".to_string())
             }
-            HttpAction::Load { path } => {
+            McpAction::Load { path } => {
                 if let Err(e) = state.load_from_file(Path::new(&path)) {
                     Err(format!("Load failed: {:?}", e))
                 } else {
@@ -710,7 +708,7 @@ impl State {
                     Ok("Project loaded".to_string())
                 }
             }
-            HttpAction::Save { path } => {
+            McpAction::Save { path } => {
                 if let Err(e) = state.save_to_file(Path::new(&path)) {
                     Err(format!("Save failed: {:?}", e))
                 } else {
@@ -721,7 +719,7 @@ impl State {
                     Ok("Project saved".to_string())
                 }
             }
-            HttpAction::ToggleGeometry { slot } => {
+            McpAction::ToggleGeometry { slot } => {
                 let active_nodes = state.current_dir().children.len();
                 if slot < active_nodes {
                     if state.current_dir().children[slot].node_type == "utility" {
@@ -738,7 +736,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::AddNode { template_name, name, x, y } => {
+            McpAction::AddNode { template_name, name, x, y } => {
                 let template_idx = state.node_templates.iter().position(|t| {
                     t.label.to_lowercase() == template_name.to_lowercase()
                         || t.node.name.to_lowercase() == template_name.to_lowercase()
@@ -775,7 +773,7 @@ impl State {
                     Err(format!("Template '{}' not found", template_name))
                 }
             }
-            HttpAction::DeleteNode { slot } => {
+            McpAction::DeleteNode { slot } => {
                 if state.delete_node(slot) {
                     needs_redraw = true;
                     Ok("Node deleted".to_string())
@@ -783,7 +781,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::RenameNode { slot, new_name } => {
+            McpAction::RenameNode { slot, new_name } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
                     state.current_dir_mut().children[slot].name = new_name;
@@ -794,7 +792,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::MoveNode { slot, x, y } => {
+            McpAction::MoveNode { slot, x, y } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
                     let (nx, ny) = state.find_empty_cell(x, y, Some(slot));
@@ -809,7 +807,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::AddParam { slot, name, param_type, default } => {
+            McpAction::AddParam { slot, name, param_type, default } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
                     let param = ParamDef {
@@ -830,7 +828,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::DeleteParam { slot, name } => {
+            McpAction::DeleteParam { slot, name } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
                     let params = &mut state.current_dir_mut().children[slot].params;
@@ -846,7 +844,7 @@ impl State {
                     Err("Slot out of bounds".to_string())
                 }
             }
-            HttpAction::ToggleCircularPane => {
+            McpAction::ToggleCircularPane => {
                 state.circular_network_pane = !state.circular_network_pane;
                 let val = state.circular_network_pane;
                 state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
@@ -856,7 +854,7 @@ impl State {
                 needs_redraw = true;
                 Ok(format!("Circular pane: {}", state.circular_network_pane))
             }
-            HttpAction::MenuClick { widget_idx, menu_idx, item_idx } => {
+            McpAction::MenuClick { widget_idx, menu_idx, item_idx } => {
                 // Validate before touching menu_mut(): a non-menubar widget_idx
                 // panics its MenuBar downcast, and out-of-range menu/item indices
                 // used to reply "Menu clicked" while dispatching nowhere. NB the
@@ -891,7 +889,7 @@ impl State {
                     Err(e) => Err(e),
                 }
             }
-            HttpAction::MenuAction { label } => {
+            McpAction::MenuAction { label } => {
                 if state.execute_menu_action(&label) {
                     // The arms relayout themselves but render() draws the last
                     // uploaded buffer (same ritual as ToggleCircularPane).
@@ -901,7 +899,7 @@ impl State {
                     Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
                 }
             }
-            HttpAction::MenuClosed { widget_idx, menu_idx } => {
+            McpAction::MenuClosed { widget_idx, menu_idx } => {
                 if state.active_menu_cloud_idx == Some((widget_idx, menu_idx)) {
                     state.active_menu_cloud_pid = None;
                     state.active_menu_cloud_idx = None;