diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index 904933ea1..5f5ef90ba 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -208,7 +208,13 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result Ok(Command::Version), "tools" => Ok(Command::ToolsList), "status" => tool_call("get_document_info", vec![]), - "open" if positionals.len() == 1 => tool_call("get_document_info", vec![]), + "open" => { + let args = flag_value(flags, "file") + .or_else(|| positionals.get(1).cloned()) + .map(|path| vec![pair("filePath", path)]) + .unwrap_or_default(); + tool_call("open_document", args) + } "get" => map_get(flags), "selection" => tool_call("get_selection", vec![]), "insert" => map_insert(positionals), diff --git a/crates/op-cli/src/tests.rs b/crates/op-cli/src/tests.rs index 918e08fa3..974ef9cd6 100644 --- a/crates/op-cli/src/tests.rs +++ b/crates/op-cli/src/tests.rs @@ -115,6 +115,28 @@ fn parse_args_maps_ts_get_type_id_depth_page_to_batch_get() { ); } +#[test] +fn parse_args_maps_ts_open_to_open_document() { + let p = parse_args(&["open".to_string()]).expect("parse open"); + assert_eq!( + p.command, + Command::ToolCall { + tool: "open_document".to_string(), + args: vec![], + } + ); + + let with_path = + parse_args(&["open".to_string(), "/tmp/design.op".to_string()]).expect("parse open path"); + assert_eq!( + with_path.command, + Command::ToolCall { + tool: "open_document".to_string(), + args: vec![("filePath".to_string(), "/tmp/design.op".to_string())], + } + ); +} + #[test] fn parse_args_maps_ts_page_list_alias_to_rust_tool() { let args = vec!["page".to_string(), "list".to_string()]; diff --git a/crates/op-host-desktop/src/mcp_serve.rs b/crates/op-host-desktop/src/mcp_serve.rs index c1c7bad4d..226a4840d 100644 --- a/crates/op-host-desktop/src/mcp_serve.rs +++ b/crates/op-host-desktop/src/mcp_serve.rs @@ -35,8 +35,8 @@ use op_mcp::{ insert_node_snapshot, instantiate_component_snapshot, list_components_snapshot, list_node_kinds_snapshot, list_pages_snapshot, list_theme_presets_snapshot, list_variables_snapshot, load_theme_preset_snapshot, move_node_snapshot, - nudge_selected_snapshot, paste_clipboard_snapshot, read_nodes_snapshot, redo_snapshot, - remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot, + nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot, read_nodes_snapshot, + redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot, reorder_selected_snapshot, replace_node_snapshot, run_stdio_with_applier, save_theme_preset_snapshot, selection_snapshot, set_active_axis_value_snapshot, @@ -112,9 +112,7 @@ where if trimmed.is_empty() { return Ok(None); } - // MCP handshake / discovery methods short-circuit the tool - // dispatcher — detected via a cheap method-field sniff so JSON - // parsing stays confined to the wire parser. + // MCP handshake / discovery methods short-circuit the tool dispatcher. match sniff_method(trimmed).as_deref() { Some("initialize") => { return Ok(sniff_id_raw(trimmed).map(|id| initialize_response(&id))); @@ -377,6 +375,7 @@ fn rebuild_registry(doc: &EditorState) -> ToolRegistry { for tool in op_mcp::element_tools::insert_kit_component_tools(doc) { r.register(Box::new(tool)); } + r.register(Box::new(open_document_snapshot(doc))); r.register(Box::new(document_info_snapshot(doc))); r.register(Box::new(selection_snapshot(doc))); r.register(Box::new(get_node_snapshot(doc))); @@ -664,14 +663,8 @@ fn ping_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. 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. Debug-tool - // schemas are appended only when the isolation flag is set, - // so they stay invisible to a production client. + // The tool catalog must match `rebuild_registry`; dynamic element schemas and + // debug schemas are appended here under the same gates as registration. let mut entries: Vec = TOOL_SCHEMAS.iter().map(|s| (*s).to_string()).collect(); entries.extend(op_mcp::element_tools::element_tool_schemas(state)); if debug_tools_enabled() { @@ -686,6 +679,7 @@ fn tools_list_response(id_raw: &str, state: &EditorState) -> String { /// Per-tool JSON schemas, kept as string literals to avoid serde on this path. const TOOL_SCHEMAS: &[&str] = &[ // --- read tools --- + r#"{"name":"open_document","description":"Connect to the current Rust MCP document and return metadata, context summary, and design prompt. filePath is accepted for TS CLI compatibility; the Rust server remains bound to the document it was started with.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Accepted for TS compatibility; use live://canvas/current server document"}}}}"#, r#"{"name":"get_document_info","description":"Summarize the open document (page count, active page, etc).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_selection","description":"Return the current selection state (ids, count).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_node","description":"Read a node by id with depth-limited descendants.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string","description":"u64 node id"}},"required":["node_id"]}}"#, diff --git a/crates/op-host-desktop/src/mcp_serve/tests.rs b/crates/op-host-desktop/src/mcp_serve/tests.rs index 9432b479b..61756795e 100644 --- a/crates/op-host-desktop/src/mcp_serve/tests.rs +++ b/crates/op-host-desktop/src/mcp_serve/tests.rs @@ -57,7 +57,7 @@ fn tools_list_response_includes_all_registered_tools() { // TOOL_SCHEMAS without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 98, + 99, "tools/list catalog count must match the registered tools — add the new tool to this test" ); // Production catalog excludes debug tools (we removed the @@ -89,6 +89,7 @@ fn tools_list_response_includes_all_registered_tools() { } for name in [ "get_document_info", + "open_document", "get_selection", "get_node", "list_pages", diff --git a/crates/op-mcp/src/lib.rs b/crates/op-mcp/src/lib.rs index 79294cdd7..95088fea3 100644 --- a/crates/op-mcp/src/lib.rs +++ b/crates/op-mcp/src/lib.rs @@ -46,6 +46,9 @@ pub mod json_serializer; pub mod node_attr_tools; #[cfg(test)] mod node_attr_tools_tests; +pub mod open_document; +#[cfg(test)] +mod open_document_tests; pub mod page_tools; pub mod parser; pub mod read_nodes; @@ -123,6 +126,7 @@ pub use node_attr_tools::{ SetNodeFillHex, SetNodeFlip, SetNodeFontSize, SetNodeFontWeight, SetNodeName, SetNodeRotation, SetNodeStrokeHex, SetNodeStrokeWidth, SetNodeText, }; +pub use open_document::{open_document_snapshot, OpenDocument}; pub use page_tools::{ add_page_snapshot, clear_selection_snapshot, cycle_active_axis_value_snapshot, delete_page_snapshot, duplicate_page_snapshot, redo_snapshot, remove_page_snapshot, diff --git a/crates/op-mcp/src/open_document.rs b/crates/op-mcp/src/open_document.rs new file mode 100644 index 000000000..0d0b9f60d --- /dev/null +++ b/crates/op-mcp/src/open_document.rs @@ -0,0 +1,180 @@ +//! TS-compatible `open_document` metadata/context read tool. + +use std::collections::BTreeMap; + +use jian_ops_schema::node::PenNode; +use op_editor_core::pen_node_ext::PenNodeExt; +use op_editor_core::EditorState; +use serde_json::{json, Value}; + +use super::{get_design_prompt_snapshot, McpTool, ToolOutcome}; + +const LIVE_CANVAS_PATH: &str = "live://canvas"; + +pub struct OpenDocument { + document_json: String, + context: String, + design_prompt: String, +} + +impl McpTool for OpenDocument { + fn name(&self) -> &str { + "open_document" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let mut out = BTreeMap::new(); + out.insert("filePath".into(), LIVE_CANVAS_PATH.into()); + out.insert("document".into(), self.document_json.clone()); + out.insert("context".into(), self.context.clone()); + out.insert("designPrompt".into(), self.design_prompt.clone()); + if args + .get("filePath") + .is_some_and(|path| path != LIVE_CANVAS_PATH) + { + out.insert( + "warning".into(), + "Rust MCP is bound to the document it was started with; filePath is accepted for TS CLI compatibility but does not reopen files".into(), + ); + } + ToolOutcome::Ok(out) + } +} + +pub fn open_document_snapshot(state: &EditorState) -> OpenDocument { + OpenDocument { + document_json: build_document_json(state), + context: build_document_context(state), + design_prompt: build_open_design_prompt(state), + } +} + +fn build_document_json(state: &EditorState) -> String { + let pages_value = state.doc.pages.as_ref().map(|pages| { + Value::Array( + pages + .iter() + .map(|page| { + json!({ + "id": page.id, + "name": page.name, + "childCount": page.children.len(), + }) + }) + .collect(), + ) + }); + let total_children = state + .doc + .pages + .as_ref() + .map(|pages| pages.iter().map(|p| p.children.len()).sum()) + .unwrap_or_else(|| state.doc.children.len()); + let mut document = json!({ + "version": state.doc.version, + "childCount": total_children, + "pageCount": state.page_count(), + "hasVariables": state.doc.variables.as_ref().is_some_and(|v| !v.is_empty()), + "hasThemes": state.doc.themes.as_ref().is_some_and(|t| !t.is_empty()), + }); + if let Some(name) = &state.doc.name { + document["name"] = Value::String(name.clone()); + } + if let Some(pages) = pages_value { + document["pages"] = pages; + } + serde_json::to_string(&document).unwrap_or_else(|_| "{}".into()) +} + +fn build_document_context(state: &EditorState) -> String { + let roots = state.active_children(); + let mut nodes = Vec::new(); + collect_nodes(roots, &mut nodes); + if nodes.is_empty() { + return "Empty document. No existing nodes.".into(); + } + let summary = nodes + .iter() + .take(20) + .map(|node| { + let name = node.base().name.as_deref().unwrap_or_else(|| node.id_str()); + format!("{}:{name}", node_type(node)) + }) + .collect::>() + .join(", "); + let (width, height) = estimate_canvas_size(roots); + [ + "DOCUMENT SUMMARY:".to_string(), + format!("- Total nodes: {}", nodes.len()), + format!("- Canvas size: {width}x{height}"), + format!("- Nodes (first 20): {summary}"), + ] + .join("\n") +} + +fn build_open_design_prompt(state: &EditorState) -> String { + let roots = state.active_children(); + let total_children = state + .doc + .pages + .as_ref() + .map(|pages| pages.iter().map(|p| p.children.len()).sum()) + .unwrap_or_else(|| state.doc.children.len()); + let empty_or_empty_frame = total_children == 0 + || (roots.len() == 1 + && matches!(roots[0], PenNode::Frame(_)) + && roots[0].children().is_none_or(Vec::is_empty)); + if empty_or_empty_frame { + let prompt_tool = get_design_prompt_snapshot(state); + if let ToolOutcome::Ok(out) = prompt_tool.call(&BTreeMap::new()) { + return out.get("designPrompt").cloned().unwrap_or_default(); + } + } + "Document has existing content. Match your action to the user intent:\n\ + - READ/INSPECT: Use batch_get (search by type/name/ID) or snapshot_layout to see what is on the canvas.\n\ + - DELETE/REMOVE: Use batch_get to find the target node ID, then delete_node to remove it.\n\ + - MODIFY: Use update_node to change properties of existing nodes.\n\ + - ADD NEW: Use batch_design or insert_node.\n\ + For complex multi-section designs, use the layered workflow: design_skeleton -> design_content -> design_refine." + .into() +} + +fn collect_nodes<'a>(nodes: &'a [PenNode], out: &mut Vec<&'a PenNode>) { + for node in nodes { + out.push(node); + if let Some(children) = node.children() { + collect_nodes(children, out); + } + } +} + +fn estimate_canvas_size(nodes: &[PenNode]) -> (i32, i32) { + for node in nodes { + if matches!(node, PenNode::Frame(_)) { + let width = node.width_px().unwrap_or(1200.0) as i32; + let height = node.height_px().unwrap_or(800.0) as i32; + if width <= 500 && height >= 700 { + return (375, 812); + } + return (width, height); + } + } + (1200, 800) +} + +fn node_type(node: &PenNode) -> &'static str { + match node { + PenNode::Frame(_) => "frame", + PenNode::Group(_) => "group", + PenNode::Rectangle(_) => "rectangle", + PenNode::Ellipse(_) => "ellipse", + PenNode::Line(_) => "line", + PenNode::Polygon(_) => "polygon", + PenNode::Path(_) => "path", + PenNode::Text(_) => "text", + PenNode::TextInput(_) => "text_input", + PenNode::Image(_) => "image", + PenNode::IconFont(_) => "icon_font", + PenNode::Ref(_) => "ref", + } +} diff --git a/crates/op-mcp/src/open_document_tests.rs b/crates/op-mcp/src/open_document_tests.rs new file mode 100644 index 000000000..f74ef8d5e --- /dev/null +++ b/crates/op-mcp/src/open_document_tests.rs @@ -0,0 +1,32 @@ +//! TS-compatible `open_document` parity tests. + +use std::collections::BTreeMap; + +use super::test_fixtures::sample; +use super::{open_document_snapshot, McpTool, ToolOutcome}; + +#[test] +fn open_document_reports_live_document_metadata_context_and_prompt() { + let state = sample(); + let tool = open_document_snapshot(&state); + let args = BTreeMap::new(); + + match tool.call(&args) { + ToolOutcome::Ok(out) => { + assert_eq!(out.get("filePath"), Some(&"live://canvas".to_string())); + let document: serde_json::Value = + serde_json::from_str(out.get("document").expect("document json")) + .expect("document json"); + assert_eq!(document["childCount"], 1); + assert_eq!(document["pageCount"], 1); + assert_eq!(document["hasVariables"], false); + assert!(out + .get("context") + .is_some_and(|context| context.contains("DOCUMENT SUMMARY"))); + assert!(out + .get("designPrompt") + .is_some_and(|prompt| prompt.contains("READ/INSPECT"))); + } + other => panic!("expected Ok, got {other:?}"), + } +}