collective

Browse / github.com/storytold/gridcraft / patch

feat: route MCP tools to the visible browser workbook

feat: route MCP tools to the visible browser workbook Headless MCP tools operate on another workbook rather than the workbook displayed in the browser. Reuse GridCraft's MCP schemas and dispatcher through a borrowing backend on the normal frame thread. Admit only the same-origin parent, bound queue/identity retention, and prevent duplicate request identities from executing a mutation twice. Preserve upstream preference persistence after browser commands. Validation evidence: - Built the maintained aggregate browser patch against GridCraft v0.12.1 (`f86aec12dabc744b5f75e1033f87bfe685e78481`). - Passed focused acceptance in disposable browser co

Status
open
Score
0.00
Adopters
0 other operators
Author
muttleydosomething (imported from pull request #2667, open)
Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86
Size
+125 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
785bdee8dcfea7d680a51713

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/gridcraft-web/Cargo.toml apps/gridcraft-web/src/host.rs apps/gridcraft-web/src/web.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -2107,6 +2107,7 @@ dependencies = [  "eframe",  "egui",  "gridcraft-engine",+ "gridcraft-mcp",  "gridcraft-ui-egui",  "js-sys",  "log",diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml--- a/apps/gridcraft-web/Cargo.toml+++ b/apps/gridcraft-web/Cargo.toml@@ -15,6 +15,7 @@ path = "src/main.rs"  [dependencies] gridcraft-engine = { workspace = true }+gridcraft-mcp = { workspace = true } gridcraft-ui-egui = { workspace = true } egui = { workspace = true } log = { workspace = true }diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs--- a/apps/gridcraft-web/src/host.rs+++ b/apps/gridcraft-web/src/host.rs@@ -9,14 +9,19 @@ //!   as PDF. The parent stores it and reports failures itself. //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away. //! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//! - `gridcraft:mcp` (from the parent): `{ id, method, params }`, with a unique string id and+//!   `tools/list` or `tools/call`; `gridcraft:mcp-result` returns `{ id, result }`. //! //! Only the parent window on the page's own origin is heard or answered. In host mode the page //! starts with an empty workbook instead of a sample, and the parent guards leaving the page.  use std::cell::{Cell, RefCell};+use std::collections::HashSet; use std::rc::Rc; +use gridcraft_mcp::{Backend, ToolResult}; use gridcraft_ui_egui::{Inbox, Services, SheetApp};+use serde_json::{Value, json}; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure;@@ -28,6 +33,17 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"]; /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +// Retain request identities for this page's lifetime. At the bound, reject new work rather+// than evict identities and risk executing a delayed duplicate mutation a second time.+const MAX_MCP_IDS: usize = 16_384;+const MAX_MCP_QUEUE: usize = 128;++#[derive(Default)]+struct McpInbox {+    seen: HashSet<String>,+    queued: Vec<(String, Value)>,+}+ #[derive(Clone)] pub struct Bridge {     window: web_sys::Window,@@ -37,6 +53,8 @@ pub struct Bridge {     saved: Rc<RefCell<Vec<String>>>,     /// The unsaved-changes state last reported to the parent.     reported: Rc<Cell<Option<bool>>>,+    /// Requests are admitted by the listener and run only on the app's normal frame thread.+    mcp: Rc<RefCell<McpInbox>>, }  impl Bridge {@@ -48,7 +66,7 @@ impl Bridge {             return None;         }         let origin = window.location().origin().ok()?;-        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })+        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default(), mcp: Rc::default() })     }      /// Send saved and exported files to the parent instead of downloading them, and hear files@@ -79,6 +97,68 @@ impl Bridge {         self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);     } +    /// Reuse upstream MCP schemas, dispatch and command handling against the visible workbook.+    pub fn poll_mcp(&self, app: &mut SheetApp, ctx: &egui::Context) {+        let queued = std::mem::take(&mut self.mcp.borrow_mut().queued);+        for (id, request) in queued {+            let result = match request.get("method").and_then(Value::as_str) {+                Some("tools/list") => json!({"tools": gridcraft_mcp::tool_definitions()}),+                Some("tools/call") => {+                    let params = request.get("params").unwrap_or(&Value::Null);+                    match params.get("name").and_then(Value::as_str) {+                        Some(name) => {+                            gridcraft_mcp::call_tool(&mut BrowserBackend { app, ctx }, name, params.get("arguments").unwrap_or(&Value::Null))+                                .to_value()+                        }+                        None => ToolResult::error("tools/call requires a tool name").to_value(),+                    }+                }+                _ => ToolResult::error("unsupported browser MCP method; use tools/list or tools/call").to_value(),+            };+            self.post_mcp(&id, result);+        }+    }++    fn post_mcp(&self, id: &str, result: Value) {+        // JSON round-tripping retains the upstream MCP content-block/result shape.+        if let Ok(result) = js_sys::JSON::parse(&result.to_string()) {+            self.send(&[("type", "gridcraft:mcp-result".into()), ("id", id.into()), ("result", result)]);+        }+    }++    fn enqueue_mcp(&self, data: &JsValue, ctx: &egui::Context) {+        let Some(id) = string(data, "id").filter(|id| !id.is_empty() && id.len() <= 200) else {+            self.post_mcp("", ToolResult::error("browser MCP requires a nonempty string id of at most 200 bytes").to_value());+            return;+        };+        let mut inbox = self.mcp.borrow_mut();+        let error = if inbox.seen.contains(&id) {+            Some("duplicate browser MCP request id; the request will not run again")+        } else if inbox.seen.len() >= MAX_MCP_IDS {+            Some("browser MCP request identity limit reached; new work is blocked for this page")+        } else {+            // Remember even a rejected request: redelivery can never turn rejection into a mutation.+            inbox.seen.insert(id.clone());+            if inbox.queued.len() >= MAX_MCP_QUEUE { Some("browser MCP request queue is full") } else { None }+        };+        if let Some(error) = error {+            drop(inbox);+            self.post_mcp(&id, ToolResult::error(error).to_value());+            return;+        }+        let request = js_sys::JSON::stringify(data).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str::<Value>(&s).ok());+        match request {+            Some(request) => {+                inbox.queued.push((id, request));+                ctx.request_repaint();+            }+            None => {+                drop(inbox);+                self.post_mcp(&id, ToolResult::error("invalid browser MCP request").to_value());+            }+        }+    }+     /// After each frame: a workbook handed to the parent this frame counts as saved under the     /// name it was saved as, and the parent hears when unsaved changes appear or go away.     pub fn sync(&self, app: &mut SheetApp) {@@ -112,11 +192,16 @@ impl Bridge {     fn listen(&self, inbox: Inbox, ctx: egui::Context) {         let parent = self.parent.clone();         let origin = self.origin.clone();+        let bridge = self.clone();         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {             if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {                 return;             }             let data = event.data();+            if string(&data, "type").as_deref() == Some("gridcraft:mcp") {+                bridge.enqueue_mcp(&data, &ctx);+                return;+            }             if string(&data, "type").as_deref() != Some("gridcraft:open") {                 return;             }@@ -137,6 +222,39 @@ impl Bridge {     } } +/// A borrowing backend routes upstream MCP tools into the app; it never creates another Session.+struct BrowserBackend<'a> {+    app: &'a mut SheetApp,+    ctx: &'a egui::Context,+}++impl Backend for BrowserBackend<'_> {+    fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {+        // Browser screenshots require asynchronous frame capture and are outside this adapter.+        if method == "ui.screenshot" {+            return Err("browser screenshots are not supported by this MCP adapter".into());+        }+        match gridcraft_ui_egui::control::handle(self.app, self.ctx, method, &params) {+            gridcraft_ui_egui::control::Outcome::Done(reply) => {+                if reply.get("ok").and_then(Value::as_bool) == Some(true) {+                    Ok(reply.get("result").cloned().unwrap_or(Value::Null))+                } else {+                    Err(reply.get("error").and_then(Value::as_str).unwrap_or("browser command failed").to_string())+                }+            }+            gridcraft_ui_egui::control::Outcome::Screenshot { .. } => Err("asynchronous browser screenshots are unsupported".into()),+        }+    }++    fn has_ui(&self) -> bool {+        false+    }++    fn describe(&self) -> String {+        "the visible browser workbook".into()+    }+}+ fn is_workbook(name: &str) -> bool {     name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e))) }diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs--- a/apps/gridcraft-web/src/web.rs+++ b/apps/gridcraft-web/src/web.rs@@ -154,6 +154,10 @@ impl eframe::App for WebShell {             });         }         self.app.logic(ctx);+        // Apply browser commands before persisting preferences changed in this frame.+        if let Some(bridge) = &self.bridge {+            bridge.poll_mcp(&mut self.app, ctx);+        }         // Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).         let changed = self.prefs.changed(&self.app);         if !changed.is_empty() {