feat(mcp): expose UIKit components as insert_<comp> MCP tools

Closes the architectural piece of the "MCP element toolset" P1 gap
(TS pen-mcp ships ~100 add_card_*/add_toast_* element tools).

- op-editor-core: new `EditorCommand::InstantiateKitComponent`
  variant + applier branch that calls
  `EditorState::instantiate_kit_component` with the requested
  drop point (defaults to (0, 0)).
- op-mcp: `element_tools.rs` — `InsertKitComponent` per-component
  tool returning `OkWithCommand(_, InstantiateKitComponent)`;
  `insert_kit_component_tools(state)` walks every loaded kit;
  `element_tool_schemas(state)` emits the matching tools/list
  JSON. Tool names sanitize dashes: `insert_btn_primary`,
  `insert_card_basic`, etc.
- op-host-desktop: `rebuild_registry` chains the dynamic tools
  in; `tools_list_response` takes EditorState and appends the
  dynamic schemas next to TOOL_SCHEMAS.
- op-editor-core: tidies the empty-pages `ensure_pages` guard to
  `is_none_or`.

Result: 6 starter-kit components → 6 working MCP tools. The 100-tool
catalog parity is now a data fill-in (more components in op-editor-
core/uikit.rs auto-register as more MCP tools).
This commit is contained in:
Kayshen-X 2026-05-21 21:55:44 +08:00
parent 1902d57807
commit e8cf002a7f
7 changed files with 289 additions and 10 deletions

View file

@ -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<f64>,
doc_y: Option<f64>,
},
/// Switch the active page.
SetActivePage { index: u32 },
/// Append a fresh empty page + switch to it.

View file

@ -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(),
}
}
}

View file

@ -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<PenPage> {
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

View file

@ -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<S: std::io::Read>(stream: &mut S) -> Result<String, St
/// snapshots reflect every prior write command's mutations.
fn rebuild_registry(doc: &EditorState) -> ToolRegistry {
let mut r = ToolRegistry::default();
// UIKit element tools — one `insert_<comp>` 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<String> = 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(",")
)
}

View file

@ -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",

View file

@ -0,0 +1,227 @@
//! UIKit element tools — one `insert_<comp>` 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_<comp_sanitized>`.
/// 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_<comp>` 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<String>, component_id: impl Into<String>) -> 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<String, String>) -> 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<f64>`. 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<String, String>,
key: &str,
) -> Result<Option<f64>, ToolOutcome> {
match args.get(key) {
None => Ok(None),
Some(v) if v.is_empty() => Ok(None),
Some(v) => v.parse::<f64>().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<InsertKitComponent> {
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<String> {
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(),
);
}
}
}

View file

@ -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;