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

commit0f4a0bcf90a31b056ded34d867dc13884fef96e8
parent7b3230f2b2
authorLucas Galante <[email protected]>
date2026-07-16 14:05
feat: MCP server exposing the automation surface as tools on port 3001

Every HttpAction variant becomes an MCP tool (tool name = the variant's
serde tag, dispatched by injecting "action" and deserializing) plus
get_state, served by cce-ui's new tools-only mcp module on 127.0.0.1:3001
(CCE_DESIGNER_MCP_PORT overrides). The old inline PostAction match in
apply_custom_event is factored into apply_http_action so HTTP and MCP
share one action path; test_mcp_tools_map_to_http_actions guards the
tool-list/enum mapping. Attach with:
claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp

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

 CLAUDE.md          |  12 +-
 src/api.rs         | 199 ++++++++++++++++++
 src/app.rs         |   3 +
 src/application.rs |   5 +-
 src/main.rs        |  34 +++
 src/window.rs      | 593 +++++++++++++++++++++++++++++------------------------
 6 files changed, 571 insertions(+), 275 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4d5bdbb..d39fc41 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,7 +39,7 @@ 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
+### 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`
@@ -47,6 +47,16 @@ JSON body (`add_node`, `set_param`, `menu_action`, `save`, `load`, …— see th
 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
+`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.
+
 ## Architecture
 
 The designer runs on cce-ui's standard `Application` trait / `engine::run` pattern
diff --git a/src/api.rs b/src/api.rs
index 9d20cf3..d08c2a2 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -1,5 +1,7 @@
 use std::net::TcpListener;
 use std::io::{BufRead, BufReader, Read, Write};
+use cce_ui::mcp::McpTool;
+use serde_json::json;
 use crate::{CustomEvent, HttpAction};
 
 pub fn start_http_server(server_sender: calloop::channel::Sender<CustomEvent>) {
@@ -132,3 +134,200 @@ pub fn start_http_server(server_sender: calloop::channel::Sender<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
+/// `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.
+    let port: u16 = std::env::var("CCE_DESIGNER_MCP_PORT")
+        .ok()
+        .and_then(|p| p.parse().ok())
+        .unwrap_or(3001);
+    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`
+/// 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> {
+    let tool = |name: &str, description: &str, schema: serde_json::Value| McpTool {
+        name: name.to_string(),
+        description: description.to_string(),
+        input_schema: schema,
+    };
+    let no_args = || json!({ "type": "object", "properties": {} });
+    let slot = |desc: &str| json!({ "type": "integer", "description": desc });
+    vec![
+        tool(
+            "get_state",
+            "Get the current project state (node tree with params, cameras, pan, current path, selection) as JSON.",
+            no_args(),
+        ),
+        tool(
+            "up",
+            "Navigate up one level in the node network (out of the current subnet).",
+            no_args(),
+        ),
+        tool(
+            "enter",
+            "Enter the subnet/node at the given slot index in the current network level.",
+            json!({
+                "type": "object",
+                "properties": { "slot": slot("Child index in the current network level") },
+                "required": ["slot"],
+            }),
+        ),
+        tool(
+            "set_param",
+            "Set a parameter on the node at the given slot. All values are strings (e.g. \"1.5\", \"0.2,0.4,1\").",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": slot("Child index in the current network level"),
+                    "name": { "type": "string", "description": "Parameter name" },
+                    "value": { "type": "string", "description": "New value, as a string" },
+                },
+                "required": ["slot", "name", "value"],
+            }),
+        ),
+        tool("reset_camera", "Reset the 3D viewport camera rotation and zoom.", no_args()),
+        tool(
+            "load",
+            "Load a project (a project directory containing state.json, or a single state .json file).",
+            json!({
+                "type": "object",
+                "properties": { "path": { "type": "string", "description": "Filesystem path" } },
+                "required": ["path"],
+            }),
+        ),
+        tool(
+            "save",
+            "Save the current project to the given path.",
+            json!({
+                "type": "object",
+                "properties": { "path": { "type": "string", "description": "Filesystem path" } },
+                "required": ["path"],
+            }),
+        ),
+        tool(
+            "toggle_geometry",
+            "Toggle geometry visibility for the node at the given slot (not valid on utility nodes).",
+            json!({
+                "type": "object",
+                "properties": { "slot": slot("Child index in the current network level") },
+                "required": ["slot"],
+            }),
+        ),
+        tool(
+            "add_node",
+            "Add a node from a template (e.g. \"Sphere\") at grid position (x, y) in the current network level.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "template_name": { "type": "string", "description": "Template label or type, case-insensitive" },
+                    "name": { "type": "string", "description": "Optional node name; auto-numbered when omitted" },
+                    "x": { "type": "number", "description": "Grid column" },
+                    "y": { "type": "number", "description": "Grid row" },
+                },
+                "required": ["template_name", "x", "y"],
+            }),
+        ),
+        tool(
+            "delete_node",
+            "Delete the node at the given slot in the current network level.",
+            json!({
+                "type": "object",
+                "properties": { "slot": slot("Child index in the current network level") },
+                "required": ["slot"],
+            }),
+        ),
+        tool(
+            "rename_node",
+            "Rename the node at the given slot.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": slot("Child index in the current network level"),
+                    "new_name": { "type": "string" },
+                },
+                "required": ["slot", "new_name"],
+            }),
+        ),
+        tool(
+            "move_node",
+            "Move the node at the given slot to grid position (x, y).",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": slot("Child index in the current network level"),
+                    "x": { "type": "number", "description": "Grid column" },
+                    "y": { "type": "number", "description": "Grid row" },
+                },
+                "required": ["slot", "x", "y"],
+            }),
+        ),
+        tool(
+            "add_param",
+            "Add a parameter to the node at the given slot.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": slot("Child index in the current network level"),
+                    "name": { "type": "string" },
+                    "param_type": { "type": "string", "description": "e.g. float, int, slider, float3, spinbox, choice" },
+                    "default": { "type": "string", "description": "Default value, as a string" },
+                },
+                "required": ["slot", "name", "param_type", "default"],
+            }),
+        ),
+        tool(
+            "delete_param",
+            "Delete a parameter from the node at the given slot.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": slot("Child index in the current network level"),
+                    "name": { "type": "string", "description": "Parameter name" },
+                },
+                "required": ["slot", "name"],
+            }),
+        ),
+        tool("toggle_circular_pane", "Toggle the circular network pane.", no_args()),
+        tool(
+            "menu_click",
+            "Click a menubar item by indices (widget_idx must be a menubar widget slot).",
+            json!({
+                "type": "object",
+                "properties": {
+                    "widget_idx": { "type": "integer", "description": "Widget slot of the menubar" },
+                    "menu_idx": { "type": "integer", "description": "Menu index within the menubar" },
+                    "item_idx": { "type": "integer", "description": "Item index within the menu" },
+                },
+                "required": ["widget_idx", "menu_idx", "item_idx"],
+            }),
+        ),
+        tool(
+            "menu_closed",
+            "Notify that a menu cloud was closed (clears the active menu-cloud state).",
+            json!({
+                "type": "object",
+                "properties": {
+                    "widget_idx": { "type": "integer" },
+                    "menu_idx": { "type": "integer" },
+                },
+                "required": ["widget_idx", "menu_idx"],
+            }),
+        ),
+        tool(
+            "menu_action",
+            "Execute a menu action by its label (e.g. \"Show Spreadsheet Pane\", \"Save\") — reaches label-matched menu-pane items that menu_click's index dispatch cannot.",
+            json!({
+                "type": "object",
+                "properties": { "label": { "type": "string", "description": "Menu item label" } },
+                "required": ["label"],
+            }),
+        ),
+    ]
+}
diff --git a/src/app.rs b/src/app.rs
index b0ab1ec..137abb3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -344,6 +344,9 @@ pub enum HttpAction {
 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`.
+    McpCall(cce_ui::mcp::McpToolCall),
     /// App-requested exit (menu File > Exit, HTTP menu_action): the engine's
     /// update hook is the only place with exit access, so input handlers that
     /// see `exit_requested` route it here.
diff --git a/src/application.rs b/src/application.rs
index 7376486..ef7e995 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;
+use crate::api::{start_http_server, start_mcp_server};
 use crate::app::{CustomEvent, PendingWindowDrag, State, TouchPhase, LEFT_MENUBAR_IDX};
 use crate::window::{LocalPosition, WindowEvent};
 
@@ -148,7 +148,8 @@ impl Application for State {
         let is_detached_network = std::env::args().any(|arg| arg == "--detached-network");
         let state = State::new(is_detached_network);
         if !is_detached_network {
-            start_http_server(sender);
+            start_http_server(sender.clone());
+            start_mcp_server(sender);
         }
         state
     }
diff --git a/src/main.rs b/src/main.rs
index 06fa2ad..c15d841 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -740,5 +740,39 @@ mod tests {
         assert_eq!(mgr.match_action(&mods_ctrl_shift, &key_tab), Some(Action::PrevContext));
         assert_eq!(mgr.match_action(&mods_none, &key_tab), None);
     }
+
+    #[test]
+    fn test_mcp_tools_map_to_http_actions() {
+        // Every MCP tool except get_state must dispatch by injecting its name
+        // as the HttpAction 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();
+        assert!(tools.iter().any(|t| t.name == "get_state"));
+        let mut names = std::collections::HashSet::new();
+        for tool in &tools {
+            assert!(names.insert(tool.name.clone()), "duplicate tool name: {}", tool.name);
+            assert_eq!(tool.input_schema["type"], "object", "{}: schema must be an object", tool.name);
+            if tool.name == "get_state" {
+                continue;
+            }
+            let mut args = serde_json::Map::new();
+            if let Some(props) = tool.input_schema["properties"].as_object() {
+                for (key, prop) in props {
+                    let dummy = match prop["type"].as_str() {
+                        Some("integer") => serde_json::json!(0),
+                        Some("number") => serde_json::json!(0.0),
+                        Some("string") => serde_json::json!("x"),
+                        Some("boolean") => serde_json::json!(false),
+                        other => panic!("{}.{}: unhandled schema type {:?}", tool.name, key, other),
+                    };
+                    args.insert(key.clone(), dummy);
+                }
+            }
+            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));
+        }
+    }
 }
 
diff --git a/src/window.rs b/src/window.rs
index fd422f2..2caafec 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -576,283 +576,17 @@ impl State {
             let state = &mut *self;
             match event {
                 CustomEvent::GetState(tx) => {
-                    let proj = Project {
-                        name: "Project".to_string(),
-                        root: state.fs_root.clone(),
-                        view_state: ProjectViewState {
-                            active_camera: state.active_camera.clone(),
-                            pan: (state.pan_x, state.pan_y),
-                            current_path: state.current_path.clone(),
-                            selected_node: state.graph().selected_node(),
-                        },
-                    };
-                    let json = serde_json::to_string_pretty(&proj).unwrap_or_default();
+                    let json = serde_json::to_string_pretty(&state.project_snapshot()).unwrap_or_default();
                     let _ = tx.send(json);
                 }
                 CustomEvent::PostAction(action, tx) => {
-                    let res = match action {
-                        HttpAction::Up => {
-                            if state.move_up() {
-                                needs_redraw = true;
-                                Ok("Moved up".to_string())
-                            } else {
-                                Err("Already at root".to_string())
-                            }
-                        }
-                        HttpAction::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);
-                                state.on_path_changed();
-                                needs_redraw = true;
-                                Ok("Entered subnet".to_string())
-                            } else {
-                                Err("Not a valid subnet".to_string())
-                            }
-                        }
-                        HttpAction::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.
-                                    state.apply_settings_from_menubar_subnets();
-                                    state.sync_grid_settings();
-                                    state.sync_nodes();
-                                    state.rebuild_scene_geometry();
-                                    needs_redraw = true;
-                                    Ok("Parameter updated".to_string())
-                                } else {
-                                    Err(format!("Parameter {} not found", name))
-                                }
-                            } else {
-                                Err("Slot index out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::ResetCamera => {
-                            if state.active_camera != "Default Camera" {
-                                state.update_active_camera_rotation_reset();
-                            } else {
-                                state.viewport_mut().rotation_y = 0.0;
-                                state.viewport_mut().rotation_x = 0.0;
-                            }
-                            state.viewport_mut().zoom = 1.0;
-                            state.viewport_mut().reset_velocity();
-                            needs_redraw = true;
-                            Ok("Camera reset".to_string())
-                        }
-                        HttpAction::Load { path } => {
-                            if let Err(e) = state.load_from_file(Path::new(&path)) {
-                                Err(format!("Load failed: {:?}", e))
-                            } else {
-                                needs_redraw = true;
-                                Ok("Project loaded".to_string())
-                            }
-                        }
-                        HttpAction::Save { path } => {
-                            if let Err(e) = state.save_to_file(Path::new(&path)) {
-                                Err(format!("Save failed: {:?}", e))
-                            } else {
-                                let path_buf = Path::new(&path).to_path_buf();
-                                state.loaded_project_path = Some(path_buf.clone());
-                                state.add_recent_file(path_buf);
-                                needs_redraw = true;
-                                Ok("Project saved".to_string())
-                            }
-                        }
-                        HttpAction::ToggleGeometry { slot } => {
-                            let active_nodes = state.current_dir().children.len();
-                            if slot < active_nodes {
-                                if state.current_dir().children[slot].node_type == "utility" {
-                                    Err("Cannot toggle geometry visibility on utility nodes".to_string())
-                                } else {
-                                    let visible = !state.current_dir().children[slot].geometry_visible;
-                                    state.current_dir_mut().children[slot].geometry_visible = visible;
-                                    state.sync_nodes();
-                                    state.rebuild_scene_geometry();
-                                    needs_redraw = true;
-                                    Ok(format!("Geometry visible: {}", visible))
-                                }
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::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()
-                            });
-                            if let Some(idx) = template_idx {
-                                let mut node = state.node_templates[idx].node.clone();
-                                let mut allowed = true;
-                                let is_in_utility = !state.current_path.is_empty() && state.fs_root.children[state.current_path[0]].node_type == "utility";
-                                if is_in_utility {
-                                    if crate::geometry::is_geometry_node_type(&node.node_type) {
-                                        allowed = false;
-                                    }
-                                }
-                                if !allowed {
-                                    Err("Utility nodes cannot contain geometry.".to_string())
-                                } else {
-                                    let (nx, ny) = state.find_empty_cell(x, y, None);
-                                    node.position = (nx, ny);
-                                    if let Some(n) = name {
-                                        node.name = n;
-                                    } else {
-                                        node.name = state.get_lowest_unused_name(&node.name);
-                                    }
-                                    state.current_dir_mut().children.push(node);
-                                    state.sync_nodes();
-                                    state.rebuild_positions();
-                                    state.apply_layout();
-                                    state.update_panel_bounds();
-                                    state.rebuild_scene_geometry();
-                                    needs_redraw = true;
-                                    Ok("Node added".to_string())
-                                }
-                            } else {
-                                Err(format!("Template '{}' not found", template_name))
-                            }
-                        }
-                        HttpAction::DeleteNode { slot } => {
-                            if state.delete_node(slot) {
-                                needs_redraw = true;
-                                Ok("Node deleted".to_string())
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::RenameNode { slot, new_name } => {
-                            let len = state.current_dir().children.len();
-                            if slot < len {
-                                state.current_dir_mut().children[slot].name = new_name;
-                                state.sync_nodes();
-                                needs_redraw = true;
-                                Ok("Node renamed".to_string())
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::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));
-                                state.current_dir_mut().children[slot].position = (nx, ny);
-                                state.sync_nodes();
-                                state.rebuild_positions();
-                                state.apply_layout();
-                                state.update_panel_bounds();
-                                needs_redraw = true;
-                                Ok("Node moved".to_string())
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::AddParam { slot, name, param_type, default } => {
-                            let len = state.current_dir().children.len();
-                            if slot < len {
-                                let param = ParamDef {
-                                    name,
-                                    label: String::new(),
-                                    param_type,
-                                    default,
-                                    options: vec![],
-                                    min: None,
-                                    max: None,
-                                    step: None,
-                                };
-                                state.current_dir_mut().children[slot].params.push(param);
-                                state.sync_nodes();
-                                needs_redraw = true;
-                                Ok("Parameter added".to_string())
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::DeleteParam { slot, name } => {
-                            let len = state.current_dir().children.len();
-                            if slot < len {
-                                let params = &mut state.current_dir_mut().children[slot].params;
-                                if let Some(pos) = params.iter().position(|p| p.name == name) {
-                                    params.remove(pos);
-                                    state.sync_nodes();
-                                    needs_redraw = true;
-                                    Ok("Parameter deleted".to_string())
-                                } else {
-                                    Err(format!("Parameter '{}' not found", name))
-                                }
-                            } else {
-                                Err("Slot out of bounds".to_string())
-                            }
-                        }
-                        HttpAction::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);
-                            state.rebuild_positions();
-                            state.apply_layout();
-                            state.sync_grid_settings();
-                            needs_redraw = true;
-                            Ok(format!("Circular pane: {}", state.circular_network_pane))
-                        }
-                        HttpAction::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
-                            // pane-toggle items ("Show Spreadsheet Pane", ...) are NOT in these
-                            // menubars — they are button params in the menu pane, drained by
-                            // sync_parameters_to_project's label match, unreachable from here.
-                            let validated: Result<String, String> = if widget_idx >= WIDGET_COUNT {
-                                Err(format!("widget_idx {widget_idx} out of range (widget slots: 0..{WIDGET_COUNT})"))
-                            } else if let Some(menubar) = state.menubar_at(widget_idx) {
-                                match menubar.menu_dropdowns.get(menu_idx) {
-                                    None => Err(format!(
-                                        "menu_idx {menu_idx} out of range: menubar {widget_idx} has {} menus",
-                                        menubar.menu_dropdowns.len()
-                                    )),
-                                    // The reply body is interpolated into JSON unescaped, so
-                                    // keep these messages free of quotes/backslashes.
-                                    Some(items) => items.get(item_idx).cloned().ok_or_else(|| format!(
-                                        "item_idx {item_idx} out of range: menu {menu_idx} has {} items: [{}]",
-                                        items.len(), items.join(", ")
-                                    )),
-                                }
-                            } else {
-                                Err(format!("widget_idx {widget_idx} is not a menubar"))
-                            };
-                            match validated {
-                                Ok(label) => {
-                                    state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
-                                    let _ = state.process_window_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
-                                    needs_redraw = true;
-                                    Ok(format!("Menu clicked: {label}"))
-                                }
-                                Err(e) => Err(e),
-                            }
-                        }
-                        HttpAction::MenuAction { label } => {
-                            if state.execute_menu_action(&label) {
-                                // The arms relayout themselves but render() draws the last
-                                // uploaded buffer (same ritual as ToggleCircularPane).
-                                needs_redraw = true;
-                                Ok(format!("Menu action executed: {}", label.replace(['"', '\\'], "'")))
-                            } else {
-                                Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
-                            }
-                        }
-                        HttpAction::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;
-                            }
-                            Ok("Menu closed".to_string())
-                        }
-
-                    };
+                    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);
+                }
                 // Exit is handled by the Application::update wrapper
                 // (autosave + engine exit) before this is reached.
                 CustomEvent::Exit => {}
@@ -866,6 +600,321 @@ impl State {
         }
         needs_redraw
     }
+
+    /// Snapshot the project (node tree + view state) for state queries.
+    fn project_snapshot(&self) -> Project {
+        Project {
+            name: "Project".to_string(),
+            root: self.fs_root.clone(),
+            view_state: ProjectViewState {
+                active_camera: self.active_camera.clone(),
+                pan: (self.pan_x, self.pan_y),
+                current_path: self.current_path.clone(),
+                selected_node: self.graph().selected_node(),
+            },
+        }
+    }
+
+    /// An MCP tool call: `get_state` returns the project snapshot; every
+    /// other tool name is an `HttpAction` tag — injected into the arguments
+    /// and run through the shared action path.
+    fn apply_mcp_call(
+        &mut self,
+        call: &cce_ui::mcp::McpToolCall,
+        needs_redraw: &mut bool,
+    ) -> Result<serde_json::Value, String> {
+        if call.name == "get_state" {
+            return serde_json::to_value(self.project_snapshot())
+                .map_err(|e| format!("failed to serialize state: {e}"));
+        }
+        let mut req = if call.arguments.is_object() {
+            call.arguments.clone()
+        } else {
+            serde_json::json!({})
+        };
+        req["action"] = serde_json::Value::String(call.name.clone());
+        match serde_json::from_value::<HttpAction>(req) {
+            Ok(action) => self
+                .apply_http_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(
+        &mut self,
+        action: HttpAction,
+        redraw: &mut bool,
+    ) -> Result<String, String> {
+        let mut needs_redraw = false;
+        let state = self;
+        let res = match action {
+            HttpAction::Up => {
+                if state.move_up() {
+                    needs_redraw = true;
+                    Ok("Moved up".to_string())
+                } else {
+                    Err("Already at root".to_string())
+                }
+            }
+            HttpAction::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);
+                    state.on_path_changed();
+                    needs_redraw = true;
+                    Ok("Entered subnet".to_string())
+                } else {
+                    Err("Not a valid subnet".to_string())
+                }
+            }
+            HttpAction::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.
+                        state.apply_settings_from_menubar_subnets();
+                        state.sync_grid_settings();
+                        state.sync_nodes();
+                        state.rebuild_scene_geometry();
+                        needs_redraw = true;
+                        Ok("Parameter updated".to_string())
+                    } else {
+                        Err(format!("Parameter {} not found", name))
+                    }
+                } else {
+                    Err("Slot index out of bounds".to_string())
+                }
+            }
+            HttpAction::ResetCamera => {
+                if state.active_camera != "Default Camera" {
+                    state.update_active_camera_rotation_reset();
+                } else {
+                    state.viewport_mut().rotation_y = 0.0;
+                    state.viewport_mut().rotation_x = 0.0;
+                }
+                state.viewport_mut().zoom = 1.0;
+                state.viewport_mut().reset_velocity();
+                needs_redraw = true;
+                Ok("Camera reset".to_string())
+            }
+            HttpAction::Load { path } => {
+                if let Err(e) = state.load_from_file(Path::new(&path)) {
+                    Err(format!("Load failed: {:?}", e))
+                } else {
+                    needs_redraw = true;
+                    Ok("Project loaded".to_string())
+                }
+            }
+            HttpAction::Save { path } => {
+                if let Err(e) = state.save_to_file(Path::new(&path)) {
+                    Err(format!("Save failed: {:?}", e))
+                } else {
+                    let path_buf = Path::new(&path).to_path_buf();
+                    state.loaded_project_path = Some(path_buf.clone());
+                    state.add_recent_file(path_buf);
+                    needs_redraw = true;
+                    Ok("Project saved".to_string())
+                }
+            }
+            HttpAction::ToggleGeometry { slot } => {
+                let active_nodes = state.current_dir().children.len();
+                if slot < active_nodes {
+                    if state.current_dir().children[slot].node_type == "utility" {
+                        Err("Cannot toggle geometry visibility on utility nodes".to_string())
+                    } else {
+                        let visible = !state.current_dir().children[slot].geometry_visible;
+                        state.current_dir_mut().children[slot].geometry_visible = visible;
+                        state.sync_nodes();
+                        state.rebuild_scene_geometry();
+                        needs_redraw = true;
+                        Ok(format!("Geometry visible: {}", visible))
+                    }
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::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()
+                });
+                if let Some(idx) = template_idx {
+                    let mut node = state.node_templates[idx].node.clone();
+                    let mut allowed = true;
+                    let is_in_utility = !state.current_path.is_empty() && state.fs_root.children[state.current_path[0]].node_type == "utility";
+                    if is_in_utility {
+                        if crate::geometry::is_geometry_node_type(&node.node_type) {
+                            allowed = false;
+                        }
+                    }
+                    if !allowed {
+                        Err("Utility nodes cannot contain geometry.".to_string())
+                    } else {
+                        let (nx, ny) = state.find_empty_cell(x, y, None);
+                        node.position = (nx, ny);
+                        if let Some(n) = name {
+                            node.name = n;
+                        } else {
+                            node.name = state.get_lowest_unused_name(&node.name);
+                        }
+                        state.current_dir_mut().children.push(node);
+                        state.sync_nodes();
+                        state.rebuild_positions();
+                        state.apply_layout();
+                        state.update_panel_bounds();
+                        state.rebuild_scene_geometry();
+                        needs_redraw = true;
+                        Ok("Node added".to_string())
+                    }
+                } else {
+                    Err(format!("Template '{}' not found", template_name))
+                }
+            }
+            HttpAction::DeleteNode { slot } => {
+                if state.delete_node(slot) {
+                    needs_redraw = true;
+                    Ok("Node deleted".to_string())
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::RenameNode { slot, new_name } => {
+                let len = state.current_dir().children.len();
+                if slot < len {
+                    state.current_dir_mut().children[slot].name = new_name;
+                    state.sync_nodes();
+                    needs_redraw = true;
+                    Ok("Node renamed".to_string())
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::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));
+                    state.current_dir_mut().children[slot].position = (nx, ny);
+                    state.sync_nodes();
+                    state.rebuild_positions();
+                    state.apply_layout();
+                    state.update_panel_bounds();
+                    needs_redraw = true;
+                    Ok("Node moved".to_string())
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::AddParam { slot, name, param_type, default } => {
+                let len = state.current_dir().children.len();
+                if slot < len {
+                    let param = ParamDef {
+                        name,
+                        label: String::new(),
+                        param_type,
+                        default,
+                        options: vec![],
+                        min: None,
+                        max: None,
+                        step: None,
+                    };
+                    state.current_dir_mut().children[slot].params.push(param);
+                    state.sync_nodes();
+                    needs_redraw = true;
+                    Ok("Parameter added".to_string())
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::DeleteParam { slot, name } => {
+                let len = state.current_dir().children.len();
+                if slot < len {
+                    let params = &mut state.current_dir_mut().children[slot].params;
+                    if let Some(pos) = params.iter().position(|p| p.name == name) {
+                        params.remove(pos);
+                        state.sync_nodes();
+                        needs_redraw = true;
+                        Ok("Parameter deleted".to_string())
+                    } else {
+                        Err(format!("Parameter '{}' not found", name))
+                    }
+                } else {
+                    Err("Slot out of bounds".to_string())
+                }
+            }
+            HttpAction::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);
+                state.rebuild_positions();
+                state.apply_layout();
+                state.sync_grid_settings();
+                needs_redraw = true;
+                Ok(format!("Circular pane: {}", state.circular_network_pane))
+            }
+            HttpAction::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
+                // pane-toggle items ("Show Spreadsheet Pane", ...) are NOT in these
+                // menubars — they are button params in the menu pane, drained by
+                // sync_parameters_to_project's label match, unreachable from here.
+                let validated: Result<String, String> = if widget_idx >= WIDGET_COUNT {
+                    Err(format!("widget_idx {widget_idx} out of range (widget slots: 0..{WIDGET_COUNT})"))
+                } else if let Some(menubar) = state.menubar_at(widget_idx) {
+                    match menubar.menu_dropdowns.get(menu_idx) {
+                        None => Err(format!(
+                            "menu_idx {menu_idx} out of range: menubar {widget_idx} has {} menus",
+                            menubar.menu_dropdowns.len()
+                        )),
+                        // The reply body is interpolated into JSON unescaped, so
+                        // keep these messages free of quotes/backslashes.
+                        Some(items) => items.get(item_idx).cloned().ok_or_else(|| format!(
+                            "item_idx {item_idx} out of range: menu {menu_idx} has {} items: [{}]",
+                            items.len(), items.join(", ")
+                        )),
+                    }
+                } else {
+                    Err(format!("widget_idx {widget_idx} is not a menubar"))
+                };
+                match validated {
+                    Ok(label) => {
+                        state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
+                        let _ = state.process_window_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
+                        needs_redraw = true;
+                        Ok(format!("Menu clicked: {label}"))
+                    }
+                    Err(e) => Err(e),
+                }
+            }
+            HttpAction::MenuAction { label } => {
+                if state.execute_menu_action(&label) {
+                    // The arms relayout themselves but render() draws the last
+                    // uploaded buffer (same ritual as ToggleCircularPane).
+                    needs_redraw = true;
+                    Ok(format!("Menu action executed: {}", label.replace(['"', '\\'], "'")))
+                } else {
+                    Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
+                }
+            }
+            HttpAction::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;
+                }
+                Ok("Menu closed".to_string())
+            }
+
+        };
+        if needs_redraw {
+            *redraw = true;
+        }
+        res
+    }
 }