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
@@ -2107,6 +2107,7 @@ dependencies = [ "eframe", "egui", "gridcraft-engine",+ "gridcraft-mcp", "gridcraft-ui-egui", "js-sys", "log",@@ -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 }@@ -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, ¶ms) {+ 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))) }@@ -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() {