feat(shell-core/mcp): GetDocumentInfo — first first-party tool

`get_document_info` reports page count + active page index + total
node count over the registry. Smallest of the ~20 tools TS pen-mcp
exposes; serves as the wire-format smoke test for real LLM clients
and demonstrates the registration shape future tools follow.

  - `GetDocumentInfo { page_count, active_page_index, total_nodes }`
    is a snapshot struct — pre-computed at registration so each
    `dispatch` is O(1). Mutates as documents change requires
    re-registering (the server binary will do this on every doc
    edit; v1 ships a frozen snapshot).
  - `document_info_snapshot(&Document)` walks every page +
    recursively counts subtree nodes. Container nodes count as
    themselves + their descendants (matches TS's
    `flattenNodes(...).length`).

Tests (1):
  - Build a 3-node doc (Frame + 2 children), snapshot, register,
    dispatch, verify each field round-trips through the JSON-RPC
    response.

#7 MCP now ~60% — types + registry + wire format + stdio listener +
first registered tool. Remaining: ~19 more first-party tools
(insert_node, batch_design, design_skeleton, ...) — each a focused
follow-up using the same `McpTool` impl shape.

Tests total: 246 shell-core (+1). Wasm32 build clean.
This commit is contained in:
Kayshen-X 2026-05-14 15:51:53 +08:00
parent cf48a006de
commit afadde7231

View file

@ -268,6 +268,53 @@ fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
Some(line[val_start..val_start + end_rel].trim())
}
/// First-party `get_document_info` tool — reports the active
/// page index, page count, and total node count. The simplest of
/// the MCP tools TS `pen-mcp` exposes; serves as a wire-format
/// smoke test for real LLM clients.
pub struct GetDocumentInfo {
pub page_count: usize,
pub active_page_index: usize,
pub total_nodes: usize,
}
impl McpTool for GetDocumentInfo {
fn name(&self) -> &str {
"get_document_info"
}
fn call(&self, _args: &BTreeMap<String, String>) -> ToolOutcome {
let mut out = BTreeMap::new();
out.insert("page_count".into(), self.page_count.to_string());
out.insert(
"active_page_index".into(),
self.active_page_index.to_string(),
);
out.insert("total_nodes".into(), self.total_nodes.to_string());
ToolOutcome::Ok(out)
}
}
/// Snapshot the document into a `GetDocumentInfo` tool. Counts all
/// nodes recursively across every page. The MCP server registers
/// one of these per document; replays on every `get_document_info`
/// call without re-walking the tree.
pub fn document_info_snapshot(doc: &crate::document::Document) -> GetDocumentInfo {
let total_nodes: usize = doc
.pages
.iter()
.map(|p| p.children.iter().map(count_subtree).sum::<usize>())
.sum();
GetDocumentInfo {
page_count: doc.pages.len(),
active_page_index: doc.active_page_index,
total_nodes,
}
}
fn count_subtree(n: &crate::document::Node) -> usize {
1 + n.children.iter().map(count_subtree).sum::<usize>()
}
#[cfg(test)]
mod tests {
use super::*;
@ -439,6 +486,41 @@ mod tests {
assert!(j.contains(r#""line1\nline2""#));
}
#[test]
fn get_document_info_reports_snapshot_via_registry() {
use crate::document::{Document, Node, NodeKind};
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
page.children.push(Node::with_children(
10,
NodeKind::Frame,
"F",
vec![
Node::leaf(11, NodeKind::Rect, "a"),
Node::leaf(12, NodeKind::Rect, "b"),
],
));
let info = document_info_snapshot(&doc);
// Frame + 2 children = 3 nodes total.
assert_eq!(info.total_nodes, 3);
let mut r = ToolRegistry::default();
r.register(Box::new(info));
let call = ToolCall {
id: RequestId::Num(1),
tool: "get_document_info".into(),
arguments: BTreeMap::new(),
};
match r.dispatch(call) {
ToolResponse::Ok { result, .. } => {
assert_eq!(result.get("total_nodes"), Some(&"3".to_string()));
assert_eq!(result.get("page_count"), Some(&"1".to_string()));
assert_eq!(result.get("active_page_index"), Some(&"0".to_string()));
}
_ => panic!("expected Ok"),
}
}
#[test]
fn registry_errors_on_unknown_tool() {
let r = ToolRegistry::default();