use brep_mcp_core::tools::app::SessionSlot;
use brep_mcp_core::tools::compose::ServerContext;
use brep_mcp_core::tools::{ToolSet, ToolSpec};
use serde_json::{json, Value};
use std::sync::Arc;
pub const SCREENSHOT: &str = "screenshot";
pub fn compose_tools(cx: Arc<ServerContext>, slot: SessionSlot, describe: &Value) -> Vec<ToolSpec> {
use brep_mcp_core::tools::{app, compose, session_engine_tools};
let mut tools = session_engine_tools(slot.clone());
tools.extend(compose::session_tools(cx));
tools.extend(app::app_tools(slot.clone(), describe));
tools.extend(compose::pointer_tools(slot.clone()));
tools.extend(compose::capture_tools(slot.clone()));
tools.extend(compose::document_tools(slot.clone()));
tools.extend(compose::feature_tools(slot.clone()));
tools.extend(compose::registry_tools(slot));
tools
}
pub fn tool_list(set: &ToolSet) -> Value {
let mut out = Vec::with_capacity(set.specs.len());
for spec in &set.specs {
let mut schema = spec.input_schema.clone();
if spec.name == SCREENSHOT {
if let Some(o) = schema.as_object_mut() {
o.entry("properties").or_insert_with(|| json!({}));
}
if let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) {
props.insert(
"inline".into(),
json!({ "type": "boolean", "default": false, "description": "carry the inline PNG in the reply as `data_url` (large); the page keeps the full capture as a blob URL either way" }),
);
}
}
out.push(json!({
"name": spec.name,
"title": spec.name,
"group": spec.group,
"summary": cut(&spec.doc, SUMMARY_CHARS),
"description": format!("[{}] {}", spec.group, spec.doc),
"inputSchema": schema,
"annotations": {
"readOnlyHint": spec.annotations.read_only,
"consequentialHint": spec.annotations.destructive,
},
"waits": spec.annotations.waits,
}));
}
Value::Array(out)
}
pub const DIRECT: &[&str] = &[
"frame_info",
"doc_new",
"history_listing",
"feature_catalogue",
"feature_schema",
"feature_add",
"feature_params",
"feature_set_params",
"feature_delete",
"undo",
"redo",
"scene_entities",
"selection",
"selection_set",
"click_entity",
"mass_properties",
"zoom_to_fit",
"standard_view",
SCREENSHOT,
];
pub const DESCRIPTION_CHARS: usize = 480;
pub const SUMMARY_CHARS: usize = 140;
pub const PROPERTY_CHARS: usize = 160;
pub fn cut(text: &str, max: usize) -> String {
let text = text.trim();
if text.chars().count() <= max {
return text.to_string();
}
let head: String = text.chars().take(max).collect();
if let Some(end) = head.rfind(". ").or_else(|| head.rfind(".\n")) {
if end > max / 3 {
return head[..=end].to_string();
}
}
let mut cut: String = head.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
pub fn compact(tool: &Value) -> Value {
let mut schema = tool["inputSchema"].clone();
if let Some(o) = schema.as_object_mut() {
o.remove("$schema");
o.remove("title");
o.insert("additionalProperties".into(), json!(false));
if let Some(props) = o.get_mut("properties").and_then(Value::as_object_mut) {
for prop in props.values_mut() {
if let Some(d) = prop.get("description").and_then(Value::as_str).map(|d| cut(d, PROPERTY_CHARS)) {
prop["description"] = json!(d);
}
}
}
}
json!({
"name": tool["name"],
"title": tool["title"],
"description": cut(tool["description"].as_str().unwrap_or(""), DESCRIPTION_CHARS),
"inputSchema": schema,
"annotations": tool["annotations"],
})
}
pub fn direct_list(set: &ToolSet) -> Value {
let all = tool_list(set);
let all = all.as_array().cloned().unwrap_or_default();
Value::Array(
DIRECT
.iter()
.filter_map(|name| all.iter().find(|t| t["name"] == *name))
.map(compact)
.collect(),
)
}
pub fn take_inline(name: &str, args: &mut Value) -> bool {
if name != SCREENSHOT {
return false;
}
args.as_object_mut()
.and_then(|o| o.remove("inline"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
#[cfg(target_arch = "wasm32")]
pub use web::*;
#[cfg(target_arch = "wasm32")]
mod web {
use super::*;
use crate::automation::command::{describe, Envelope as AppEnvelope, Reply as AppReply};
use crate::automation::queue::AutomationQueue;
use brep_mcp_core::host::{Backend, HostInfo, HostRequest, KeptShot, Platform, PlatformFuture, Reply};
use brep_mcp_core::tools::compose::attach_session;
use brep_mcp_core::tools::ToolOutput;
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{future_to_promise, spawn_local};
const REPLY_TIMEOUT_MS: f64 = 120_000.0;
const KEEP_SHOTS: usize = 8;
thread_local! {
static TOOLS: RefCell<Option<Arc<ToolSet>>> = const { RefCell::new(None) };
static FAILED: RefCell<Option<String>> = const { RefCell::new(None) };
static ATTACHED: Cell<bool> = const { Cell::new(false) };
static SHOTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
}
struct Timer {
ms: u64,
armed: bool,
fired: Arc<AtomicBool>,
}
impl Timer {
fn new(ms: u64) -> Self {
Self { ms, armed: false, fired: Arc::new(AtomicBool::new(false)) }
}
}
impl Future for Timer {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.fired.load(Ordering::Acquire) {
return Poll::Ready(());
}
if !self.armed {
self.armed = true;
let fired = self.fired.clone();
let waker = cx.waker().clone();
let callback = Closure::once_into_js(move || {
fired.store(true, Ordering::Release);
waker.wake();
});
match web_sys::window() {
Some(win) => {
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), self.ms as i32);
}
None => {
self.fired.store(true, Ordering::Release);
cx.waker().wake_by_ref();
}
}
}
Poll::Pending
}
}
fn sleep(ms: u64) -> Timer {
Timer::new(ms)
}
fn now_ms() -> f64 {
js_sys::Date::now()
}
async fn forward(q: &Arc<AutomationQueue>, cmd: String, args: Value) -> Reply {
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let rx = q.submit(AppEnvelope { id, cmd, args });
let started = now_ms();
loop {
match rx.try_recv() {
Ok(reply) => return convert(reply),
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Reply::err(id, q.frame(), "the app dropped the command"),
Err(std::sync::mpsc::TryRecvError::Empty) => {
if q.is_poisoned() {
return Reply::err(id, q.frame(), "session poisoned");
}
if now_ms() - started > REPLY_TIMEOUT_MS {
return Reply::err(id, q.frame(), format!("no reply from the app within {} s (is the tab visible?)", REPLY_TIMEOUT_MS / 1000.0));
}
sleep(16).await;
}
}
}
}
fn convert(mut r: AppReply) -> Reply {
let blob = r.blob.take();
let (id, frame) = (r.id, r.frame);
match serde_json::to_value(&r).map_err(|e| e.to_string()).and_then(|v| Reply::from_app(v, blob)) {
Ok(reply) => reply,
Err(e) => Reply::err(id, frame, e),
}
}
async fn call_ok(q: &Arc<AutomationQueue>, cmd: &str, args: Value) -> Result<Value, String> {
let r = forward(q, cmd.to_string(), args).await;
if r.ok {
Ok(r.result.unwrap_or(Value::Null))
} else {
Err(r.error.unwrap_or_else(|| format!("{cmd} failed")))
}
}
struct BrowserPlatform {
queue: Arc<AutomationQueue>,
}
impl Platform for BrowserPlatform {
fn now_ms(&self) -> f64 {
now_ms()
}
fn sleep(&self, ms: u64) -> PlatformFuture<'_, ()> {
Box::pin(sleep(ms))
}
fn spawn(&self, task: Pin<Box<dyn Future<Output = ()> + Send + 'static>>) {
spawn_local(task);
}
fn stamp(&self) -> String {
format!("{}-web", (now_ms() / 1000.0) as u64)
}
fn session_dir(&self, root: &std::path::Path, id: &str) -> Result<PathBuf, String> {
Ok(root.join(id))
}
fn append_log(&self, _dir: &std::path::Path, line: &str) {
log::debug!("webmcp: {line}");
}
fn read_text(&self, path: String) -> PlatformFuture<'_, Result<String, String>> {
let q = self.queue.clone();
Box::pin(async move {
let r = call_ok(&q, "store_read", json!({ "name": path })).await?;
match r["text"].as_str() {
Some(text) if r["exists"] == true => Ok(text.to_string()),
_ => Err(format!("{path}: not in the page's document store (store_list names what is)")),
}
})
}
fn read_bytes(&self, path: String) -> PlatformFuture<'_, Result<Vec<u8>, String>> {
let q = self.queue.clone();
Box::pin(async move {
let r = call_ok(&q, "store_read", json!({ "name": path })).await?;
match r["text"].as_str() {
Some(text) if r["exists"] == true => Ok(text.as_bytes().to_vec()),
_ => Err(format!(
"{path}: not in the page's document store (store_list names what is); a file on your machine goes through `doc_import` with its content, or the page's file chooser (`file_pick`)"
)),
}
})
}
fn write_file(&self, path: String, bytes: Vec<u8>, overwrite: bool) -> PlatformFuture<'_, Result<String, String>> {
let q = self.queue.clone();
Box::pin(async move {
let args = match String::from_utf8(bytes) {
Ok(text) => json!({ "name": path, "text": text, "overwrite": overwrite }),
Err(e) => json!({ "name": path, "base64": base64(e.as_bytes()), "overwrite": overwrite }),
};
let r = call_ok(&q, "store_write", args).await?;
Ok(r["name"].as_str().unwrap_or(&path).to_string())
})
}
fn keep_shot(&self, _dir: &std::path::Path, _n: u64, png: &[u8], _manifest: Option<&Value>) -> Result<KeptShot, String> {
let url = blob_url(png)?;
SHOTS.with(|s| {
let mut s = s.borrow_mut();
s.push(url.clone());
while s.len() > KEEP_SHOTS {
let old = s.remove(0);
let _ = web_sys::Url::revoke_object_url(&old);
}
});
Ok(KeptShot { path: url, manifest: None })
}
}
fn blob_url(png: &[u8]) -> Result<String, String> {
let array = js_sys::Uint8Array::new_with_length(png.len() as u32);
array.copy_from(png);
let parts = js_sys::Array::of1(&array);
let options = web_sys::BlobPropertyBag::new();
options.set_type("image/png");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(|_| "blob create failed".to_string())?;
web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| "object URL failed".to_string())
}
pub fn attach(queue: Arc<AutomationQueue>, adapter: String) {
if ATTACHED.with(|a| a.replace(true)) {
return;
}
crate::automation::registry::set_enabled(true);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HostRequest>();
let q = queue.clone();
spawn_local(async move {
while let Some(req) = rx.recv().await {
let q = q.clone();
match req {
HostRequest::Command { envelope, reply } => {
spawn_local(async move {
let _ = reply.send(forward(&q, envelope.cmd, envelope.args).await);
});
}
HostRequest::Screenshot { region, reply } => {
spawn_local(async move {
let r = forward(&q, "screenshot".into(), json!({ "region": region })).await;
let _ = reply.send(if !r.ok {
Err(r.error.unwrap_or_else(|| "screenshot failed".into()))
} else {
match r.blob {
Some(png) => Ok((r.result.unwrap_or(Value::Null), png)),
None => Err("the screenshot reply carried no image".into()),
}
});
});
}
HostRequest::Annotate { reply, .. } => {
let _ = reply.send(Err(
"the page draws no annotation overlay: docs walkthroughs are recorded by the headless host (brep-mcp)".to_string(),
));
}
HostRequest::Stop => {}
}
}
});
let platform: Arc<dyn Platform> = Arc::new(BrowserPlatform { queue });
let info = HostInfo { backend: "browser", adapter, platform: "web", width: 0.0, height: 0.0, ppp: 1.0 };
let slot: SessionSlot = Arc::new(tokio::sync::RwLock::new(None));
let cx = Arc::new(ServerContext {
slot: slot.clone(),
session_root: PathBuf::from("browser:/sessions"),
backend: Backend::Attached { tx, info, platform },
on_tools_changed: Arc::new(|| {}),
});
spawn_local(async move {
match attach_session(&cx, true).await {
Ok(_) => {
let set = ToolSet::new(compose_tools(cx, slot, &describe()));
TOOLS.with(|t| *t.borrow_mut() = Some(Arc::new(set)));
}
Err(e) => FAILED.with(|f| *f.borrow_mut() = Some(format!("the session did not attach: {e}"))),
}
});
}
async fn tools() -> Result<Arc<ToolSet>, String> {
loop {
if let Some(set) = TOOLS.with(|t| t.borrow().clone()) {
return Ok(set);
}
if let Some(why) = FAILED.with(|f| f.borrow().clone()) {
return Err(why);
}
if !ATTACHED.with(|a| a.get()) {
return Err("the app has not started".into());
}
sleep(16).await;
}
}
fn js_err(e: String) -> JsValue {
JsValue::from_str(&e)
}
#[wasm_bindgen]
pub fn webmcp_tools() -> js_sys::Promise {
future_to_promise(async move {
let set = tools().await.map_err(js_err)?;
Ok(JsValue::from_str(&tool_list(&set).to_string()))
})
}
#[wasm_bindgen]
pub fn webmcp_direct() -> js_sys::Promise {
future_to_promise(async move {
let set = tools().await.map_err(js_err)?;
Ok(JsValue::from_str(&direct_list(&set).to_string()))
})
}
#[wasm_bindgen]
pub fn webmcp_shots() -> js_sys::Array {
SHOTS.with(|s| s.borrow().iter().map(|u| JsValue::from_str(u)).collect())
}
#[wasm_bindgen]
pub fn webmcp_invoke(name: String, args: JsValue) -> js_sys::Promise {
future_to_promise(async move { invoke(&name, args).await.map_err(js_err) })
}
async fn invoke(name: &str, args: JsValue) -> Result<JsValue, String> {
let set = tools().await?;
let spec = set.get(name).ok_or_else(|| format!("unknown tool `{name}`"))?;
let mut args = js_to_value(&args)?;
let inline = take_inline(name, &mut args);
let out = (spec.handler)(args).await?;
to_js(out, inline)
}
fn js_to_value(args: &JsValue) -> Result<Value, String> {
if args.is_null() || args.is_undefined() {
return Ok(json!({}));
}
let text = js_sys::JSON::stringify(args).map_err(|_| "arguments are not JSON".to_string())?;
let text: String = text.into();
serde_json::from_str(&text).map_err(|e| format!("arguments: {e}"))
}
fn to_js(out: ToolOutput, inline: bool) -> Result<JsValue, String> {
let mut result = out.json;
if inline && !out.images.is_empty() {
let urls: Vec<String> = out.images.iter().map(|i| format!("data:{};base64,{}", i.mime, base64(&i.png))).collect();
result["data_url"] = json!(urls[0]);
result["data_urls"] = json!(urls);
}
let obj = js_sys::Object::new();
let parsed = js_sys::JSON::parse(&result.to_string()).map_err(|_| "the reply is not JSON".to_string())?;
js_sys::Reflect::set(&obj, &JsValue::from_str("result"), &parsed).map_err(|_| "result")?;
let images = js_sys::Array::new();
for image in &out.images {
images.push(&js_sys::Uint8Array::from(image.png.as_slice()));
}
js_sys::Reflect::set(&obj, &JsValue::from_str("images"), &images).map_err(|_| "images")?;
Ok(obj.into())
}
fn base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(TABLE[(n >> 18) as usize & 63] as char);
out.push(TABLE[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
}
out
}
}