diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index 949571882..2e67f4c67 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -163,6 +163,16 @@ pub enum EditorCommand { DeleteComponent { component_id: NodeId }, /// Rename a component. **Gap** — rejected. RenameComponent { component_id: NodeId, name: String }, + /// Instantiate a UIKit component onto the active page. The kit / + /// component lookup happens against [`crate::EditorState::ui_kits`] + /// at apply time; `(doc_x, doc_y)` default to `(0, 0)` when the + /// caller does not specify a drop point. + InstantiateKitComponent { + kit_id: String, + component_id: String, + doc_x: Option, + doc_y: Option, + }, /// Switch the active page. SetActivePage { index: u32 }, /// Append a fresh empty page + switch to it. diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index fbd8a6e98..5c5e0d8fe 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -417,6 +417,21 @@ impl EditorState { | EditorCommand::CreateComponent { .. } | EditorCommand::DeleteComponent { .. } | EditorCommand::RenameComponent { .. } => false, + + // --- UIKit element insert ------------------------------- + EditorCommand::InstantiateKitComponent { + kit_id, + component_id, + doc_x, + doc_y, + } => self + .instantiate_kit_component( + &kit_id, + &component_id, + doc_x.unwrap_or(0.0), + doc_y.unwrap_or(0.0), + ) + .is_some(), } } } diff --git a/crates/op-editor-core/src/page_mutators.rs b/crates/op-editor-core/src/page_mutators.rs index 5e03762be..66dd9e9fe 100644 --- a/crates/op-editor-core/src/page_mutators.rs +++ b/crates/op-editor-core/src/page_mutators.rs @@ -35,11 +35,7 @@ impl EditorState { /// would be stranded the moment `add_page` minted a fresh Page 1 /// alongside them. fn ensure_pages(&mut self) -> &mut Vec { - let needs_init = self - .doc - .pages - .as_ref() - .map_or(true, |pages| pages.is_empty()); + let needs_init = self.doc.pages.as_ref().is_none_or(|pages| pages.is_empty()); if needs_init { // Mint the page id BEFORE moving the root children out — // `max_node_id` must see the nodes that are migrating so diff --git a/crates/op-host-desktop/src/mcp_serve.rs b/crates/op-host-desktop/src/mcp_serve.rs index 18c5e195d..3df121804 100644 --- a/crates/op-host-desktop/src/mcp_serve.rs +++ b/crates/op-host-desktop/src/mcp_serve.rs @@ -98,7 +98,7 @@ fn process_message( return Ok(sniff_id_raw(trimmed).map(|id| initialize_response(&id))); } Some("tools/list") => { - return Ok(sniff_id_raw(trimmed).map(|id| tools_list_response(&id))); + return Ok(sniff_id_raw(trimmed).map(|id| tools_list_response(&id, state))); } Some("notifications/initialized") | Some("initialized") => { return Ok(None); // notification — no response required @@ -306,6 +306,12 @@ fn read_http_request_body(stream: &mut S) -> Result ToolRegistry { let mut r = ToolRegistry::default(); + // UIKit element tools — one `insert_` per kit component. + // Registered first so a future static-tool name collision fails + // loudly at tools/list (the registry de-duplicates by name). + for tool in op_mcp::element_tools::insert_kit_component_tools(doc) { + r.register(Box::new(tool)); + } r.register(Box::new(document_info_snapshot(doc))); r.register(Box::new(selection_snapshot(doc))); r.register(Box::new(get_node_snapshot(doc))); @@ -570,13 +576,18 @@ fn ping_response(id_raw: &str) -> String { format!(r#"{{"jsonrpc":"2.0","id":{id_raw},"result":{{}}}}"#) } -fn tools_list_response(id_raw: &str) -> String { +fn tools_list_response(id_raw: &str, state: &EditorState) -> String { // The tool catalog must match what `rebuild_registry` // installs. Schemas are minimal but sufficient for an MCP - // client to render a tool picker + validate calls. + // client to render a tool picker + validate calls. Dynamic + // UIKit element tools (one per kit component) are appended + // alongside the static schemas — the kit set lives on + // `EditorState`, so they're computed per call. + let mut entries: Vec = TOOL_SCHEMAS.iter().map(|s| (*s).to_string()).collect(); + entries.extend(op_mcp::element_tools::element_tool_schemas(state)); format!( r#"{{"jsonrpc":"2.0","id":{id_raw},"result":{{"tools":[{}]}}}}"#, - TOOL_SCHEMAS.join(",") + entries.join(",") ) } diff --git a/crates/op-host-desktop/src/mcp_serve/tests.rs b/crates/op-host-desktop/src/mcp_serve/tests.rs index c98804f18..4affc90e0 100644 --- a/crates/op-host-desktop/src/mcp_serve/tests.rs +++ b/crates/op-host-desktop/src/mcp_serve/tests.rs @@ -43,7 +43,8 @@ fn initialize_response_includes_protocol_and_capabilities() { #[test] fn tools_list_response_includes_all_registered_tools() { - let r = tools_list_response("3"); + let state = op_editor_core::EditorState::new(); + let r = tools_list_response("3", &state); // Exact-count assertion: any tool added without // updating this test will trip the count first. Codex // stop-gate: previous `contains`-only checks would have @@ -54,6 +55,24 @@ fn tools_list_response_includes_all_registered_tools() { 82, "tools/list catalog count must match the registered tools — add the new tool to this test" ); + // UIKit element tools are appended dynamically — one per + // built-in starter-kit component (6) — and ride alongside + // the static schemas in the tools/list response. + assert_eq!( + op_mcp::element_tools::element_tool_schemas(&state).len(), + 6, + "starter kit ships 6 element tools — update this if the kit grows" + ); + for name in [ + "insert_btn_primary", + "insert_input_text", + "insert_card_basic", + "insert_nav_bar", + "insert_divider", + "insert_badge", + ] { + assert!(r.contains(name), "tools/list must include element tool {name}"); + } for name in [ "get_document_info", "get_selection", diff --git a/crates/op-mcp/src/element_tools.rs b/crates/op-mcp/src/element_tools.rs new file mode 100644 index 000000000..f31447803 --- /dev/null +++ b/crates/op-mcp/src/element_tools.rs @@ -0,0 +1,227 @@ +//! UIKit element tools — one `insert_` MCP tool per built-in +//! [`UIKit`] component, so an LLM client can drop a Primary Button +//! (etc.) onto the canvas without first having to learn the kit-id +//! / component-id pair. The TS counterpart is `pen-mcp`'s ~100 +//! `add_card_v0` / `add_toast_v0` element tools. +//! +//! Each tool returns `ToolOutcome::OkWithCommand(map, +//! EditorCommand::InstantiateKitComponent { … })` so the host's +//! applier runs the same `EditorState::instantiate_kit_component` +//! path the Component-Browser panel uses — deep-clone, fresh-id, +//! subtree-translate, select, history-snapshot. + +use std::collections::BTreeMap; + +use op_editor_core::{EditorCommand, EditorState, UIKit}; + +use super::{McpTool, ToolErrorCode, ToolOutcome}; + +/// Sanitize a `kit-id` / `component-id` for embedding in a tool name +/// — MCP tool names are `[a-zA-Z0-9_]+`, so dashes become underscores. +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }) + .collect() +} + +/// The MCP tool name for a kit component: `insert_`. +/// v1 ships one starter kit so the kit prefix is dropped for terseness; +/// a future imported-kits surface can fold the kit id back in once +/// collisions are possible. +pub fn element_tool_name(component_id: &str) -> String { + format!("insert_{}", sanitize(component_id)) +} + +/// `insert_` MCP tool — instantiates one UIKit component onto +/// the active page through the editor command bus. +pub struct InsertKitComponent { + name: String, + kit_id: String, + component_id: String, +} + +impl InsertKitComponent { + pub fn new(kit_id: impl Into, component_id: impl Into) -> Self { + let component_id = component_id.into(); + Self { + name: element_tool_name(&component_id), + kit_id: kit_id.into(), + component_id, + } + } +} + +impl McpTool for InsertKitComponent { + fn name(&self) -> &str { + &self.name + } + fn call(&self, args: &BTreeMap) -> ToolOutcome { + // `x` / `y` are optional doc-px floats; omitted slots default + // to 0.0 at apply time. + let doc_x = match parse_optional_f64(args, "x") { + Ok(v) => v, + Err(e) => return e, + }; + let doc_y = match parse_optional_f64(args, "y") { + Ok(v) => v, + Err(e) => return e, + }; + let mut result = BTreeMap::new(); + result.insert("kit_id".into(), self.kit_id.clone()); + result.insert("component_id".into(), self.component_id.clone()); + ToolOutcome::OkWithCommand( + result, + EditorCommand::InstantiateKitComponent { + kit_id: self.kit_id.clone(), + component_id: self.component_id.clone(), + doc_x, + doc_y, + }, + ) + } +} + +/// Parse a number arg as `Option`. An absent slot returns `None` +/// (the command falls back to 0.0); a malformed slot is a hard error +/// so the LLM client retries with a valid value. +/// +/// The `Err` variant carries a full `ToolOutcome::Err` — the call site +/// returns it verbatim, so the large size is intentional. +#[allow(clippy::result_large_err)] +fn parse_optional_f64( + args: &BTreeMap, + key: &str, +) -> Result, ToolOutcome> { + match args.get(key) { + None => Ok(None), + Some(v) if v.is_empty() => Ok(None), + Some(v) => v.parse::().map(Some).map_err(|_| { + ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("{key} must be a number"), + ) + }), + } +} + +/// Walk every loaded kit and emit one [`InsertKitComponent`] per +/// component. The host's `rebuild_registry` chains this into the live +/// `ToolRegistry`. +pub fn insert_kit_component_tools(state: &EditorState) -> Vec { + state + .ui_kits + .iter() + .flat_map(|kit: &UIKit| { + kit.components + .iter() + .map(|c| InsertKitComponent::new(kit.id.clone(), c.id.clone())) + }) + .collect() +} + +/// JSON-encoded `tools/list` schema for one element tool. The host +/// concatenates this into the `tools/list` response next to the static +/// `TOOL_SCHEMAS`. +pub fn element_tool_schema(component_name: &str, component_id: &str) -> String { + let tool = element_tool_name(component_id); + format!( + r#"{{"name":"{tool}","description":"Insert a {component_name} from the built-in UIKit onto the active page. Optional x/y doc-px floats place the top-left; defaults to (0, 0).","inputSchema":{{"type":"object","properties":{{"x":{{"type":"string","description":"top-left doc-px (float)"}},"y":{{"type":"string","description":"top-left doc-px (float)"}}}}}}}}"# + ) +} + +/// JSON-encoded schemas for every element tool the live state has — +/// matches the iterator order of [`insert_kit_component_tools`] so +/// counts agree. +pub fn element_tool_schemas(state: &EditorState) -> Vec { + state + .ui_kits + .iter() + .flat_map(|kit| { + kit.components + .iter() + .map(|c| element_tool_schema(&c.name, &c.id)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_sanitizes_dashes() { + assert_eq!(element_tool_name("btn-primary"), "insert_btn_primary"); + assert_eq!(element_tool_name("nav-bar"), "insert_nav_bar"); + } + + #[test] + fn tool_emits_instantiate_command() { + let tool = InsertKitComponent::new("openpencil-starter", "btn-primary"); + assert_eq!(tool.name(), "insert_btn_primary"); + let mut args = BTreeMap::new(); + args.insert("x".to_string(), "120".to_string()); + args.insert("y".to_string(), "80".to_string()); + match tool.call(&args) { + ToolOutcome::OkWithCommand(_, cmd) => match cmd { + EditorCommand::InstantiateKitComponent { + kit_id, + component_id, + doc_x, + doc_y, + } => { + assert_eq!(kit_id, "openpencil-starter"); + assert_eq!(component_id, "btn-primary"); + assert_eq!(doc_x, Some(120.0)); + assert_eq!(doc_y, Some(80.0)); + } + _ => panic!("expected InstantiateKitComponent"), + }, + other => panic!("expected OkWithCommand, got {other:?}"), + } + } + + #[test] + fn missing_x_y_default_to_none() { + let tool = InsertKitComponent::new("openpencil-starter", "badge"); + match tool.call(&BTreeMap::new()) { + ToolOutcome::OkWithCommand( + _, + EditorCommand::InstantiateKitComponent { doc_x, doc_y, .. }, + ) => { + assert_eq!(doc_x, None); + assert_eq!(doc_y, None); + } + other => panic!("expected OkWithCommand, got {other:?}"), + } + } + + #[test] + fn malformed_x_is_a_hard_error() { + let tool = InsertKitComponent::new("openpencil-starter", "badge"); + let mut args = BTreeMap::new(); + args.insert("x".to_string(), "not-a-number".to_string()); + match tool.call(&args) { + ToolOutcome::Err(ToolErrorCode::InvalidArgument, _) => {} + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + + #[test] + fn registry_covers_every_starter_kit_component() { + let state = EditorState::new(); + let tools = insert_kit_component_tools(&state); + let schemas = element_tool_schemas(&state); + assert_eq!(tools.len(), 6, "starter kit ships 6 components"); + assert_eq!(schemas.len(), tools.len(), "schema + tool counts agree"); + // Each tool name appears verbatim in its schema. + for tool in &tools { + assert!( + schemas + .iter() + .any(|s| s.contains(&format!("\"name\":\"{}\"", tool.name()))), + "schema set must include {}", + tool.name(), + ); + } + } +} diff --git a/crates/op-mcp/src/lib.rs b/crates/op-mcp/src/lib.rs index f1e9397d5..37e02f355 100644 --- a/crates/op-mcp/src/lib.rs +++ b/crates/op-mcp/src/lib.rs @@ -29,6 +29,7 @@ pub mod component_tools; mod component_tools_tests; #[cfg(test)] mod copy_node_tests; +pub mod element_tools; pub mod extra_read_tools; #[cfg(test)] mod extra_read_tools_tests;