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

src/api.rs (12.4K)

  1 //! The embedded MCP automation server — the way to drive/inspect the
  2 //! running app (the former bespoke HTTP API was retired in its favor).
  3 
  4 use cce_ui::mcp::McpTool;
  5 use serde_json::json;
  6 use crate::CustomEvent;
  7 
  8 /// Start the embedded MCP server (cce-ui's tools-only Streamable HTTP
  9 /// implementation). Agents attach with
 10 /// `claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`.
 11 pub fn start_mcp_server(server_sender: calloop::channel::Sender<CustomEvent>) {
 12     // CCE_DESIGNER_MCP_PORT overrides the default so a second instance
 13     // (tests, debugging) can run alongside one already holding 3001.
 14     let port: u16 = std::env::var("CCE_DESIGNER_MCP_PORT")
 15         .ok()
 16         .and_then(|p| p.parse().ok())
 17         .unwrap_or(3001);
 18     cce_ui::mcp::start_mcp_server("cce-designer", port, mcp_tools(), server_sender, CustomEvent::McpCall);
 19 }
 20 
 21 /// The designer's MCP tools: `get_state` plus one tool per `McpAction`
 22 /// variant — the tool name is the variant's serde tag and the arguments are
 23 /// its fields, so dispatch is deserialization (see `apply_mcp_call`).
 24 pub(crate) fn mcp_tools() -> Vec<McpTool> {
 25     let tool = |name: &str, description: &str, schema: serde_json::Value| McpTool {
 26         name: name.to_string(),
 27         description: description.to_string(),
 28         input_schema: schema,
 29     };
 30     let no_args = || json!({ "type": "object", "properties": {} });
 31     let slot = |desc: &str| json!({ "type": "integer", "description": desc });
 32     vec![
 33         tool(
 34             "get_state",
 35             "Get the current project state (node tree with params, cameras, pan, current path, selection) as JSON, plus the playbar, the network grid and the status line's text (`status`).",
 36             no_args(),
 37         ),
 38         tool(
 39             "up",
 40             "Navigate up one level in the node network (out of the current subnet).",
 41             no_args(),
 42         ),
 43         tool(
 44             "enter",
 45             "Enter the subnet/node at the given slot index in the current network level.",
 46             json!({
 47                 "type": "object",
 48                 "properties": { "slot": slot("Child index in the current network level") },
 49                 "required": ["slot"],
 50             }),
 51         ),
 52         tool(
 53             "select",
 54             "Select the node at the given slot in the current network level (like clicking it); its parameters populate the parameter pane.",
 55             json!({
 56                 "type": "object",
 57                 "properties": { "slot": slot("Child index in the current network level") },
 58                 "required": ["slot"],
 59             }),
 60         ),
 61         tool(
 62             "set_param",
 63             "Set a parameter on the node at the given slot. All values are strings (e.g. \"1.5\", \"0.2,0.4,1\"). A value that reads as an expression — ch(\"../sphere1/radius\") * 2, $F / 24 — becomes one (Houdini paths: relative to the node, .. its parent, / the root, a bare name its own parameter).",
 64             json!({
 65                 "type": "object",
 66                 "properties": {
 67                     "slot": slot("Child index in the current network level"),
 68                     "name": { "type": "string", "description": "Parameter name (radius, base_resolution); its label (Base Resolution) is accepted too" },
 69                     "value": { "type": "string", "description": "New value, as a string" },
 70                 },
 71                 "required": ["slot", "name", "value"],
 72             }),
 73         ),
 74         tool("reset_camera", "Reset the 3D viewport camera rotation and zoom.", no_args()),
 75         tool(
 76             "load",
 77             "Load a project (a project directory containing state.json, or a single state .json file).",
 78             json!({
 79                 "type": "object",
 80                 "properties": { "path": { "type": "string", "description": "Filesystem path" } },
 81                 "required": ["path"],
 82             }),
 83         ),
 84         tool(
 85             "save",
 86             "Save the current project to the given path.",
 87             json!({
 88                 "type": "object",
 89                 "properties": { "path": { "type": "string", "description": "Filesystem path" } },
 90                 "required": ["path"],
 91             }),
 92         ),
 93         tool(
 94             "toggle_bypass",
 95             "Bypass the node at the given slot, or stop bypassing it. A bypassed node stays in the graph and does nothing: what reads it gets the node's own Input.",
 96             json!({
 97                 "type": "object",
 98                 "properties": { "slot": slot("Child index in the current network level") },
 99                 "required": ["slot"],
100             }),
101         ),
102         tool(
103             "toggle_geometry",
104             "Toggle geometry visibility for the node at the given slot.",
105             json!({
106                 "type": "object",
107                 "properties": { "slot": slot("Child index in the current network level") },
108                 "required": ["slot"],
109             }),
110         ),
111         tool(
112             "add_node",
113             "Add a node from a template (e.g. \"Sphere\") at grid position (x, y) in the current network level. The root holds Geometry nodes, cameras and pages; every operator goes inside a Geometry node, and a template that does not belong at the level is refused.",
114             json!({
115                 "type": "object",
116                 "properties": {
117                     "template_name": { "type": "string", "description": "Template label or type, case-insensitive" },
118                     "name": { "type": "string", "description": "Optional node name; auto-numbered when omitted" },
119                     "x": { "type": "number", "description": "Grid column" },
120                     "y": { "type": "number", "description": "Grid row" },
121                 },
122                 "required": ["template_name", "x", "y"],
123             }),
124         ),
125         tool(
126             "delete_node",
127             "Delete the node at the given slot in the current network level.",
128             json!({
129                 "type": "object",
130                 "properties": { "slot": slot("Child index in the current network level") },
131                 "required": ["slot"],
132             }),
133         ),
134         tool(
135             "rename_node",
136             "Rename the node at the given slot.",
137             json!({
138                 "type": "object",
139                 "properties": {
140                     "slot": slot("Child index in the current network level"),
141                     "new_name": { "type": "string" },
142                 },
143                 "required": ["slot", "new_name"],
144             }),
145         ),
146         tool(
147             "move_node",
148             "Move the node at the given slot to grid position (x, y).",
149             json!({
150                 "type": "object",
151                 "properties": {
152                     "slot": slot("Child index in the current network level"),
153                     "x": { "type": "number", "description": "Grid column" },
154                     "y": { "type": "number", "description": "Grid row" },
155                 },
156                 "required": ["slot", "x", "y"],
157             }),
158         ),
159         tool(
160             "add_param",
161             "Add a parameter to the node at the given slot.",
162             json!({
163                 "type": "object",
164                 "properties": {
165                     "slot": slot("Child index in the current network level"),
166                     "name": { "type": "string", "description": "The parameter's name, what a ch() path spells: lowercase letters, digits and underscores (base_resolution)" },
167                     "label": { "type": "string", "description": "What the params pane shows for it (Base Resolution); the name when absent" },
168                     "param_type": { "type": "string", "description": "One of text, float, slider, spinbox, float3, choice, toggle, button, code, node, attribute, group — optionally with detail after a colon (slider:-2:2, choice:A,B,C)" },
169                     "default": { "type": "string", "description": "Default value, as a string" },
170                 },
171                 "required": ["slot", "name", "param_type", "default"],
172             }),
173         ),
174         tool(
175             "delete_param",
176             "Delete a parameter from the node at the given slot.",
177             json!({
178                 "type": "object",
179                 "properties": {
180                     "slot": slot("Child index in the current network level"),
181                     "name": { "type": "string", "description": "Parameter name (radius, base_resolution); its label (Base Resolution) is accepted too" },
182                 },
183                 "required": ["slot", "name"],
184             }),
185         ),
186         tool("toggle_circular_pane", "Toggle the circular network pane.", no_args()),
187         tool(
188             "set_pane_collapsed",
189             "Collapse a pane to its title stub, or expand it back.",
190             json!({
191                 "type": "object",
192                 "properties": {
193                     "pane": { "type": "string", "description": "network | parameters | spreadsheet | playbar" },
194                     "collapsed": { "type": "boolean", "description": "true to collapse, false to expand" },
195                 },
196                 "required": ["pane", "collapsed"],
197             }),
198         ),
199         tool(
200             "set_pane_detached",
201             "Move a pane into its own window, or take it back.",
202             json!({
203                 "type": "object",
204                 "properties": {
205                     "pane": { "type": "string", "description": "network | parameters | spreadsheet | playbar" },
206                     "detached": { "type": "boolean", "description": "true to detach, false to reattach" },
207                 },
208                 "required": ["pane", "detached"],
209             }),
210         ),
211         tool(
212             "set_frame",
213             "Move the playhead. Simnets solve up to this frame.",
214             json!({
215                 "type": "object",
216                 "properties": {
217                     "frame": { "type": "number", "description": "Timeline frame" },
218                 },
219                 "required": ["frame"],
220             }),
221         ),
222         tool(
223             "curve_set_points",
224             "Replace a curve node's control points (world-space [x, y, z] triples). The Catmull-Rom strip re-evaluates immediately.",
225             json!({
226                 "type": "object",
227                 "properties": {
228                     "slot": { "type": "integer", "description": "Node index in the current directory; must be a curve node" },
229                     "points": {
230                         "type": "array",
231                         "items": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
232                         "description": "Control points as [x, y, z] triples; replaces the whole list",
233                     },
234                 },
235                 "required": ["slot", "points"],
236             }),
237         ),
238         tool(
239             "menu_click",
240             "Click a menubar item by indices. Only the viewport menubar's Camera menu (menu 0) and the parameters menubar's Preset and Reset menus (0, 1) are dispatched this way; everything else is a command, see run_command.",
241             json!({
242                 "type": "object",
243                 "properties": {
244                     "widget_idx": { "type": "integer", "description": "Widget slot of the menubar" },
245                     "menu_idx": { "type": "integer", "description": "Menu index within the menubar" },
246                     "item_idx": { "type": "integer", "description": "Item index within the menu" },
247                 },
248                 "required": ["widget_idx", "menu_idx", "item_idx"],
249             }),
250         ),
251         tool(
252             "run_command",
253             "Run a command by its registry id (e.g. \"toggle_dialog\", \"toggle_grid\", \"save_document\") — every command the dialog lists, including the ones no menu label reaches.",
254             json!({
255                 "type": "object",
256                 "properties": { "id": { "type": "string", "description": "Command id, snake_case, as input.kdl binds it" } },
257                 "required": ["id"],
258             }),
259         ),
260         tool(
261             "menu_action",
262             "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.",
263             json!({
264                 "type": "object",
265                 "properties": { "label": { "type": "string", "description": "Menu item label" } },
266                 "required": ["label"],
267             }),
268         ),
269     ]
270 }