BREP_mcp_core 0.3.0

The BREP MCP server core: the Model Context Protocol surface generated from the CAD app's own registries, its sessions, script runner and transports (stdio and streamable HTTP). Embedded in the app behind `brep-app --mcp`; hosted headlessly by BREP_mcp.
Documentation
//! Render the registries to the checked-in generated docs
//! (`docs/developer/mcp/generated/`): what a reviewer diffs to see what a change
//! exposed, and what the freshness test compares against. Nobody edits these.
//!
//! Only the tool half exists until the app's state and hit-key registries land;
//! the same writer will take their sections then.
use crate::tools::ToolSet;
use serde_json::{json, Value};
use std::collections::BTreeMap;

pub const HEADER: &str = "<!-- GENERATED by `brep-mcp schema --write docs/developer/mcp/generated`; do not edit. -->";

/// `commands.md`: one section per group, one entry per tool with its doc,
/// annotations and input schema.
pub fn commands_md(tools: &ToolSet) -> String {
    let mut groups: BTreeMap<&str, Vec<&crate::tools::ToolSpec>> = BTreeMap::new();
    for t in &tools.specs {
        groups.entry(t.group).or_default().push(t);
    }
    let mut out = String::new();
    out.push_str(HEADER);
    out.push_str("\n# MCP tools\n\n");
    out.push_str(&format!("{} tools in {} groups.\n", tools.specs.len(), groups.len()));
    for (group, specs) in &groups {
        out.push_str(&format!("\n## {group}\n"));
        for t in specs {
            let a = t.annotations;
            let mut flags = Vec::new();
            if a.read_only { flags.push("read-only"); }
            if a.destructive { flags.push("destructive"); }
            if a.idempotent { flags.push("idempotent"); }
            if a.waits { flags.push("waits for idle"); }
            out.push_str(&format!("\n### `{}`\n\n{}\n\n", t.name, t.doc.trim()));
            if !flags.is_empty() {
                out.push_str(&format!("Annotations: {}.\n\n", flags.join(", ")));
            }
            out.push_str("```json\n");
            out.push_str(&serde_json::to_string_pretty(&t.input_schema).unwrap_or_default());
            out.push_str("\n```\n");
        }
    }
    out
}

/// `commands.json`: the machine-readable twin.
pub fn commands_json(tools: &ToolSet) -> Value {
    json!({
        "tools": tools.specs.iter().map(|t| json!({
            "name": t.name,
            "group": t.group,
            "doc": t.doc,
            "annotations": {
                "readOnly": t.annotations.read_only,
                "destructive": t.annotations.destructive,
                "idempotent": t.annotations.idempotent,
                "waits": t.annotations.waits,
            },
            "inputSchema": t.input_schema,
        })).collect::<Vec<_>>()
    })
}

/// `features.md`: the catalogue as the docs see it.
pub fn features_md() -> String {
    let mut out = String::new();
    out.push_str(HEADER);
    out.push_str("\n# Feature schemas\n\n");
    for e in crate::schema::entries() {
        let id = crate::schema::identity(&e);
        let schema = crate::schema::to_json_schema(&e);
        out.push_str(&format!("\n## {} (`{}`, short `{}`)\n\n", id.long_name, id.feature_type, id.short_name));
        let props = schema["properties"].as_object().cloned().unwrap_or_default();
        out.push_str("| param | kind | default |\n|---|---|---|\n");
        for (name, p) in props {
            let kind = p["x-paramType"].as_str().unwrap_or("?");
            let default = p.get("default").map(|d| d.to_string()).unwrap_or_default();
            out.push_str(&format!("| `{name}` | {kind} | `{default}` |\n"));
        }
        if let Some(un) = schema["x-unmapped"].as_array() {
            if !un.is_empty() {
                let names: Vec<String> = un.iter().map(|u| format!("`{}`", u["name"].as_str().unwrap_or("?"))).collect();
                out.push_str(&format!("\nNot settable through tools: {}.\n", names.join(", ")));
            }
        }
        // A feature that carries geometry OUTSIDE its inputParams — the sketch
        // profile — publishes that block's shape and a working example here,
        // because `inputParamsSchema` cannot describe it.
        if crate::schema::carries_sketch(&id.feature_type) {
            out.push_str("\n`persistentData` (the profile — see `brep://schema/sketch`):\n\n```json\n");
            out.push_str(&serde_json::to_string_pretty(&crate::schema::sketch_example()).unwrap_or_default());
            out.push_str("\n```\n");
        }
    }
    out
}

/// What a live session reports about its state and hit-key registries:
/// `state` = `[{name, doc, typed, schema?}]`, `hit_keys` = `[{panel, prefix, meaning}]`.
#[derive(Default, Clone)]
pub struct Registries {
    pub state: Value,
    pub hit_keys: Value,
}

/// `state.md`: every published blob with its doc and whether its schema is
/// derived or by example.
pub fn state_md(reg: &Registries) -> String {
    let mut out = String::new();
    out.push_str(HEADER);
    // The table is what a DEFAULT session publishes: this generator starts one
    // session and reads `state_list` from it, so a blob a panel only publishes
    // while that panel is drawing is legitimately absent. Say so, rather than
    // claiming a completeness the capture cannot have.
    out.push_str("\n# State registry\n\nEvery blob the app publishes per frame in a DEFAULT session (`state_get`, `brep://state/{name}`).\n\nA panel that publishes only while it is open is absent here — the BOM, assembly-tree and constraints panes, the settings window and the import-preview dialog. Open the panel in a live session and read `state_list` for those.\n\n| name | schema | doc |\n|---|---|---|\n");
    for e in reg.state.as_array().into_iter().flatten() {
        let typed = if e["typed"].as_bool().unwrap_or(false) { "derived" } else { "by example" };
        out.push_str(&format!("| `{}` | {} | {} |\n", e["name"].as_str().unwrap_or("?"), typed, e["doc"].as_str().unwrap_or("")));
    }
    out
}

/// `widgets.md`: the hit-key prefixes each panel documents.
pub fn widgets_md(reg: &Registries) -> String {
    let mut by_panel: BTreeMap<String, Vec<&Value>> = BTreeMap::new();
    for d in reg.hit_keys.as_array().into_iter().flatten() {
        by_panel.entry(d["panel"].as_str().unwrap_or("?").to_string()).or_default().push(d);
    }
    let mut out = String::new();
    out.push_str(HEADER);
    out.push_str(
        "\n# Widget hit keys\n\nKeys are `panel/key` in `hit_rects` and `click_widget`. \
         The `command` column names the TOOL that does the same thing without a pointer, where one exists \
         — every button on the two toolbars has one. Where the server owns a file half it is named here \
         under its tool name (`document_open` / `document_save` / `document_import` / `document_export`), \
         not the app command it wraps.\n",
    );
    for (panel, docs) in by_panel {
        out.push_str(&format!("\n## {panel}\n\n| prefix | meaning | command |\n|---|---|---|\n"));
        for d in docs {
            // The app names its own command; a caller invokes the TOOL, which for
            // the file halves is the server's wrapper under another name.
            let command = match d["command"].as_str() {
                Some(name) => format!("`{}`", crate::tools::app::tool_name_for(name)),
                None => String::new(),
            };
            out.push_str(&format!(
                "| `{}` | {} | {command} |\n",
                d["prefix"].as_str().unwrap_or(""),
                d["meaning"].as_str().unwrap_or("")
            ));
        }
    }
    out
}

/// Every generated file as `(relative path, content)`.
pub fn all(tools: &ToolSet) -> Vec<(String, String)> {
    all_with(tools, &Registries::default())
}

/// The generated files including the sections only a live session can fill.
pub fn all_with(tools: &ToolSet, reg: &Registries) -> Vec<(String, String)> {
    let mut files = vec![
        ("commands.md".into(), commands_md(tools)),
        ("commands.json".into(), serde_json::to_string_pretty(&commands_json(tools)).unwrap_or_default() + "\n"),
        ("features.md".into(), features_md()),
        ("features.json".into(), serde_json::to_string_pretty(&crate::schema::all()).unwrap_or_default() + "\n"),
    ];
    if !reg.state.is_null() {
        files.push(("state.md".into(), state_md(reg)));
        files.push(("state.json".into(), serde_json::to_string_pretty(&reg.state).unwrap_or_default() + "\n"));
    }
    if !reg.hit_keys.is_null() {
        files.push(("widgets.md".into(), widgets_md(reg)));
        files.push(("widgets.json".into(), serde_json::to_string_pretty(&reg.hit_keys).unwrap_or_default() + "\n"));
    }
    files
}

/// Start a headless session on the seed model, read the three registries
/// through it, and hand back the generated tool set and the registry JSON.
/// The session is left running (the caller stops it) so the tool set stays
/// consistent with what was read.
pub async fn live_registries(server: &crate::server::BrepServer) -> Result<(ToolSet, Registries), String> {
    let start = server.state.tools.read().await.get("session_start").cloned().ok_or("no session_start tool")?;
    (start.handler)(json!({ "backend": "headless", "seed": true, "record": false })).await?;
    server.rebuild_tools().await;
    let session = crate::tools::app::current(&server.state.slot).await?;
    let state = session.host.call_ok("state_list", json!({})).await?.result.unwrap_or(Value::Null);
    let mut entries = state["entries"].as_array().cloned().unwrap_or_default();
    for e in &mut entries {
        // Per-frame facts (the blob's byte size) do not belong in a checked-in
        // file; a diff there would be noise, not a change in what is exposed.
        if let Some(o) = e.as_object_mut() {
            o.remove("bytes");
        }
        if e["typed"].as_bool() == Some(true) {
            if let Some(name) = e["name"].as_str() {
                if let Ok(r) = session.host.call_ok("state_schema", json!({ "names": [name] })).await {
                    e["schema"] = r.result.map(|v| v[name]["schema"].clone()).unwrap_or(Value::Null);
                }
            }
        }
    }
    let hit_keys = session.host.call_ok("hit_key_docs", json!({})).await?.result.map(|v| v["keys"].clone()).unwrap_or(Value::Null);
    let tools = {
        let set = server.state.tools.read().await;
        ToolSet::new(set.specs.clone())
    };
    Ok((tools, Registries { state: Value::Array(entries), hit_keys }))
}

/// Compare the full generated set with what is on disk. Returns the stale paths.
pub fn stale_with(dir: &std::path::Path, tools: &ToolSet, reg: &Registries) -> Vec<String> {
    all_with(tools, reg)
        .into_iter()
        .filter(|(rel, content)| std::fs::read_to_string(dir.join(rel)).map(|on_disk| on_disk != *content).unwrap_or(true))
        .map(|(rel, _)| rel)
        .collect()
}

/// Write the full generated set. Returns the paths written.
pub fn write_with(dir: &std::path::Path, tools: &ToolSet, reg: &Registries) -> std::io::Result<Vec<String>> {
    std::fs::create_dir_all(dir)?;
    let mut written = Vec::new();
    for (rel, content) in all_with(tools, reg) {
        std::fs::write(dir.join(&rel), content)?;
        written.push(rel);
    }
    Ok(written)
}

/// Compare the generated files with what is on disk. Returns the stale paths.
pub fn stale(dir: &std::path::Path, tools: &ToolSet) -> Vec<String> {
    all(tools)
        .into_iter()
        .filter(|(rel, content)| std::fs::read_to_string(dir.join(rel)).map(|on_disk| on_disk != *content).unwrap_or(true))
        .map(|(rel, _)| rel)
        .collect()
}

/// Write every generated file. Returns the paths written.
pub fn write(dir: &std::path::Path, tools: &ToolSet) -> std::io::Result<Vec<String>> {
    std::fs::create_dir_all(dir)?;
    let mut written = Vec::new();
    for (rel, content) in all(tools) {
        std::fs::write(dir.join(&rel), content)?;
        written.push(rel);
    }
    Ok(written)
}

// BREP private tests: 0fbc5651be0f3add