fix(mcp): add open document parity
This commit is contained in:
parent
cb1cf55ae9
commit
ce813bcb3e
|
|
@ -208,7 +208,13 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
|
|||
"version" => 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),
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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<String> = 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"]}}"#,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
180
crates/op-mcp/src/open_document.rs
Normal file
180
crates/op-mcp/src/open_document.rs
Normal file
|
|
@ -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<String, String>) -> 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::<Vec<_>>()
|
||||
.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",
|
||||
}
|
||||
}
|
||||
32
crates/op-mcp/src/open_document_tests.rs
Normal file
32
crates/op-mcp/src/open_document_tests.rs
Normal file
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue