graphic design tool
git clone https://git.lucas.co/cce-designer.git
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
+ }
}