fix(mcp): add open document parity

This commit is contained in:
Kayshen-X 2026-06-02 09:41:40 +08:00
parent cb1cf55ae9
commit ce813bcb3e
7 changed files with 254 additions and 15 deletions

View file

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

View file

@ -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()];

View file

@ -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"]}}"#,

View file

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

View file

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

View 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",
}
}

View 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:?}"),
}
}