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. -->";
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
}
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<_>>()
})
}
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(", ")));
}
}
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
}
#[derive(Default, Clone)]
pub struct Registries {
pub state: Value,
pub hit_keys: Value,
}
pub fn state_md(reg: &Registries) -> String {
let mut out = String::new();
out.push_str(HEADER);
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
}
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 {
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
}
pub fn all(tools: &ToolSet) -> Vec<(String, String)> {
all_with(tools, &Registries::default())
}
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(®.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(®.hit_keys).unwrap_or_default() + "\n"));
}
files
}
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 {
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 }))
}
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()
}
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)
}
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()
}
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)
}