Skip to main content

datagrout_panels_mcp/
lib.rs

1//! Transpile Smart Panels into **MCP Apps** ([SEP-1865]) resources.
2//!
3//! MCP Apps — the first official MCP extension, released 2026-01-26 — lets a
4//! server hand a host an interactive UI: an HTML document published as a
5//! `ui://` resource with mime type `text/html;profile=mcp-app`, rendered in a
6//! sandboxed iframe that talks JSON-RPC to the host over `postMessage`. A tool
7//! links to its view through `_meta.ui.resourceUri`.
8//!
9//! # Why the two models line up
10//!
11//! A Smart Panel is a *declarative* description of a view: kind, props, and a
12//! query that re-derives its rows. An MCP App is a *document* that receives its
13//! data by notification (`ui/notifications/tool-result`) rather than fetching
14//! it up front. So a panel transpiles cleanly: the panel's kind and props
15//! become a static template, and the rows arrive at render time exactly as the
16//! extension already intends.
17//!
18//! That means one panel definition, stored once as facts, can drive a native
19//! GUI (`datagrout-panels-egui`) and an MCP host — with no second authoring
20//! step and no divergence between the two.
21//!
22//! # Delivering rows
23//!
24//! The host sends `ui/notifications/tool-result`. The document reads rows from
25//! `structuredContent`:
26//!
27//! * a single panel: `structuredContent.rows` (or the whole `structuredContent`
28//!   if it is an array);
29//! * a dashboard: `structuredContent.panels`, an object keyed by child panel
30//!   id, each value a rows array.
31//!
32//! # Where the panel comes from
33//!
34//! Panels are published to DataGrout with the gateway's `smart_panel.publish`
35//! tool and read back with `smart_panel.list`; that response becomes `Panel`
36//! values via [`datagrout_panels::Panel::all_from_list`]. A `Panel` built by
37//! hand transpiles identically, which is how the tests here run.
38//!
39//! # What this crate does and does not do
40//!
41//! It emits documents and metadata. It does **not** serve them, register them,
42//! or speak MCP — a server does that with whatever MCP library it already uses.
43//! Keeping it a pure function of a [`Panel`] means it is testable without a
44//! host and embeddable in any server.
45//!
46//! [SEP-1865]: https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp
47
48#![forbid(unsafe_code)]
49
50use datagrout_panels::{Panel, PanelKind};
51use serde_json::{json, Value};
52
53/// The only mime type the MCP Apps MVP supports.
54pub const MCP_APP_MIME: &str = "text/html;profile=mcp-app";
55
56/// The capability key a host announces UI support under.
57pub const UI_CAPABILITY: &str = "io.modelcontextprotocol/ui";
58
59/// A transpiled panel, ready to publish as an MCP resource.
60#[derive(Debug, Clone)]
61pub struct UiResource {
62    /// `ui://<server>/<panel-id>`
63    pub uri: String,
64    pub mime_type: &'static str,
65    /// The full HTML document.
66    pub text: String,
67    /// Contents of the resource's `_meta.ui` object.
68    pub meta: Value,
69}
70
71impl UiResource {
72    /// The resource as an MCP `resources/read` payload.
73    pub fn to_resource_json(&self) -> Value {
74        json!({
75            "uri": self.uri,
76            "mimeType": self.mime_type,
77            "text": self.text,
78            "_meta": { "ui": self.meta },
79        })
80    }
81}
82
83/// Transpiler options.
84pub struct TranspileOptions {
85    /// Server segment of the `ui://` authority, e.g. `"my-app"`.
86    pub server: String,
87    /// Whether the view may call back into the host.
88    ///
89    /// A read-only display panel needs no callbacks; a form does. Defaulting to
90    /// `false` keeps the emitted view as inert as its content allows.
91    pub interactive: bool,
92    /// Host-visible border hint (`_meta.ui.prefersBorder`).
93    pub prefers_border: bool,
94}
95
96impl Default for TranspileOptions {
97    fn default() -> Self {
98        Self {
99            server: "datagrout".to_string(),
100            interactive: false,
101            prefers_border: true,
102        }
103    }
104}
105
106/// Transpile a panel into a `ui://` resource.
107pub fn to_ui_resource(panel: &Panel, opts: &TranspileOptions) -> UiResource {
108    let uri = ui_uri(&opts.server, &panel.id);
109    let interactive = opts.interactive || panel.kind.is_form_kind();
110
111    // No CSP domains are declared: the emitted document inlines its styles and
112    // script and loads nothing. Widening this is the caller's decision to make
113    // explicitly, never a default.
114    let mut meta = json!({ "prefersBorder": opts.prefers_border });
115    if let Some(obj) = meta.as_object_mut() {
116        obj.insert("csp".into(), json!({}));
117    }
118
119    UiResource {
120        uri,
121        mime_type: MCP_APP_MIME,
122        text: render_document(panel, interactive),
123        meta,
124    }
125}
126
127/// The `_meta` a tool carries to link itself to a panel's view.
128///
129/// Uses the nested `_meta.ui.*` form; the flat `_meta["ui/resourceUri"]` key is
130/// deprecated and deliberately not emitted.
131pub fn tool_meta(panel_uri: &str, visible_to_model: bool) -> Value {
132    let visibility = if visible_to_model {
133        json!(["model", "app"])
134    } else {
135        json!(["app"])
136    };
137    json!({ "ui": { "resourceUri": panel_uri, "visibility": visibility } })
138}
139
140/// Build the `ui://` URI for a panel.
141///
142/// Ids are lowercased and non-alphanumerics collapse to `-` so a Prolog atom
143/// like `revenue_chart` yields a well-formed authority path.
144pub fn ui_uri(server: &str, panel_id: &str) -> String {
145    format!("ui://{}/{}", slug(server), slug(panel_id))
146}
147
148fn slug(s: &str) -> String {
149    let mut out = String::with_capacity(s.len());
150    let mut last_dash = true; // suppress a leading dash
151    for ch in s.chars() {
152        if ch.is_ascii_alphanumeric() {
153            out.push(ch.to_ascii_lowercase());
154            last_dash = false;
155        } else if !last_dash {
156            out.push('-');
157            last_dash = true;
158        }
159    }
160    while out.ends_with('-') {
161        out.pop();
162    }
163    out
164}
165
166// ── document emission ───────────────────────────────────────────────────────
167
168/// Emit the panel's HTML document.
169///
170/// The document carries the panel's *shape* — kind, title, field structure —
171/// and a small runtime that fills in rows when the host sends
172/// `ui/notifications/tool-result`. Baking rows in at transpile time would
173/// freeze data that the panel model defines as re-derived on every read.
174fn render_document(panel: &Panel, interactive: bool) -> String {
175    let title = escape(&panel.title());
176    let kind = slug(panel.kind.as_str());
177    let description = panel
178        .description()
179        .map(|d| format!("<p class=\"desc\">{}</p>", escape(&d)))
180        .unwrap_or_default();
181    let body = body_for(panel);
182    let script = runtime_script(interactive);
183
184    format!(
185        r#"<!DOCTYPE html>
186<html lang="en">
187<head>
188<meta charset="utf-8">
189<title>{title}</title>
190<style>
191  :root {{ color-scheme: light dark; }}
192  body {{ margin: 0; padding: 12px; font: 13px/1.45 system-ui, -apple-system, sans-serif; }}
193  h1 {{ font-size: 15px; margin: 0 0 2px; }}
194  h2 {{ font-size: 13px; margin: 0 0 4px; }}
195  .desc {{ margin: 0 0 10px; opacity: .7; font-size: 12px; }}
196  table {{ border-collapse: collapse; width: 100%; }}
197  th, td {{ text-align: left; padding: 4px 8px; border-bottom: 1px solid rgba(128,128,128,.25); }}
198  th {{ font-weight: 600; font-size: 11px; text-transform: uppercase; opacity: .7; }}
199  .metric {{ font-size: 30px; font-weight: 600; font-variant-numeric: tabular-nums; }}
200  .track {{ height: 10px; border-radius: 3px; background: rgba(128,128,128,.2); overflow: hidden; }}
201  .fill {{ height: 100%; width: 0; background: currentColor; }}
202  .empty {{ opacity: .55; font-style: italic; }}
203  .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }}
204  .card {{ border: 1px solid rgba(128,128,128,.25); border-radius: 6px; padding: 10px; }}
205  .card ul {{ margin: 0; padding-left: 16px; }}
206  label {{ display: block; font-size: 11px; opacity: .75; margin: 8px 0 2px; }}
207  input, textarea, select {{ width: 100%; box-sizing: border-box; padding: 5px 7px;
208    border: 1px solid rgba(128,128,128,.4); border-radius: 5px; background: transparent;
209    color: inherit; font: inherit; }}
210</style>
211</head>
212<body data-panel-kind="{kind}" data-panel-id="{id}">
213<h1>{title}</h1>
214{description}
215{body}
216{script}
217</body>
218</html>"#,
219        id = escape(&panel.id),
220    )
221}
222
223fn body_for(panel: &Panel) -> String {
224    match &panel.kind {
225        PanelKind::Dashboard => {
226            let cards: String = panel
227                .children
228                .iter()
229                .map(|child| {
230                    let id = escape(&child.id);
231                    let title = escape(&child.title());
232                    let kind = slug(child.kind.as_str());
233                    // Every child gets the same container: a value slot for
234                    // metric-like kinds and a list slot for everything else.
235                    // The runtime picks by `data-kind`.
236                    format!(
237                        r#"<section class="card" data-child="{id}" data-kind="{kind}"><h2>{title}</h2><div class="metric" data-value hidden>—</div><ul data-rows></ul><p class="empty" data-empty>no data</p></section>"#
238                    )
239                })
240                .collect();
241            if cards.is_empty() {
242                r#"<p class="empty">no panels on this dashboard</p>"#.to_string()
243            } else {
244                format!(r#"<div class="grid" id="dg-dashboard">{cards}</div>"#)
245            }
246        }
247        PanelKind::Metric | PanelKind::Gauge => {
248            let track = if matches!(panel.kind, PanelKind::Gauge) {
249                r#"<div class="track"><div class="fill" id="dg-fill"></div></div>"#
250            } else {
251                ""
252            };
253            format!(r#"<div class="metric" id="dg-value">—</div>{track}"#)
254        }
255        PanelKind::Table => {
256            let headers: String = panel
257                .columns()
258                .iter()
259                .map(|c| format!("<th>{}</th>", escape(c)))
260                .collect();
261            format!(
262                r#"<table><thead><tr id="dg-head">{headers}</tr></thead><tbody id="dg-rows"></tbody></table>
263<p class="empty" id="dg-empty">no data</p>"#
264            )
265        }
266        k if k.is_form_kind() => {
267            let fields: String = panel
268                .fields
269                .iter()
270                .map(|f| {
271                    let label = escape(&f.label());
272                    let id = escape(&f.id);
273                    let required = if f.required() { " *" } else { "" };
274                    let control = match f.kind {
275                        PanelKind::TextArea | PanelKind::RichText => {
276                            format!(r#"<textarea id="{id}" rows="3"></textarea>"#)
277                        }
278                        PanelKind::Checkbox => {
279                            format!(r#"<input id="{id}" type="checkbox">"#)
280                        }
281                        PanelKind::NumberInput => {
282                            format!(r#"<input id="{id}" type="number">"#)
283                        }
284                        PanelKind::DateInput => format!(r#"<input id="{id}" type="date">"#),
285                        PanelKind::Button => {
286                            return format!(r#"<button id="{id}" data-dg-submit>{label}</button>"#)
287                        }
288                        _ => format!(
289                            r#"<input id="{id}" type="text" placeholder="{}">"#,
290                            escape(&f.placeholder())
291                        ),
292                    };
293                    format!("<label for=\"{id}\">{label}{required}</label>{control}")
294                })
295                .collect();
296            format!(r#"<form id="dg-form">{fields}</form>"#)
297        }
298        // Charts render as a labelled series list rather than a fabricated
299        // canvas: an MCP host draws in a small iframe of unknown size, and a
300        // readable table beats an unreadable plot.
301        _ => r#"<ul id="dg-series"></ul><p class="empty" id="dg-empty">no data</p>"#.to_string(),
302    }
303}
304
305/// The in-document runtime: the SEP-1865 handshake plus row rendering.
306fn runtime_script(interactive: bool) -> String {
307    let submit = if interactive {
308        r#"
309  document.querySelectorAll('[data-dg-submit]').forEach(function (btn) {
310    btn.addEventListener('click', function () {
311      var values = {};
312      document.querSelectorAll('#dg-form [id]').forEach(function (el) {
313        values[el.id] = el.type === 'checkbox' ? el.checked : el.value;
314      });
315      send('ui/message', { text: JSON.stringify(values) });
316    });
317  });"#
318            .replace("querSelectorAll", "querySelectorAll")
319    } else {
320        String::new()
321    };
322
323    format!(
324        r#"<script>
325(function () {{
326  var seq = 0;
327  function send(method, params) {{
328    parent.postMessage({{ jsonrpc: '2.0', id: ++seq, method: method, params: params || {{}} }}, '*');
329  }}
330  function notify(method, params) {{
331    parent.postMessage({{ jsonrpc: '2.0', method: method, params: params || {{}} }}, '*');
332  }}
333
334  function lastValue(rows) {{
335    var last = rows.length ? rows[rows.length - 1] : null;
336    return Array.isArray(last) ? last[last.length - 1] : last;
337  }}
338
339  function fillList(list, rows) {{
340    list.textContent = '';
341    rows.slice(0, 200).forEach(function (row) {{
342      var li = document.createElement('li');
343      li.textContent = (Array.isArray(row) ? row : [row]).join(' · ');
344      list.appendChild(li);
345    }});
346  }}
347
348  function renderChild(card, rows) {{
349    var kind = card.dataset.kind;
350    var empty = card.querySelector('[data-empty]');
351    if (empty) empty.hidden = rows.length > 0;
352    var value = card.querySelector('[data-value]');
353    var list = card.querySelector('[data-rows]');
354    if (kind === 'metric' || kind === 'gauge') {{
355      if (value) {{ value.hidden = false; var v = lastValue(rows); value.textContent = (v === null || v === undefined) ? '—' : v; }}
356      if (list) list.hidden = true;
357      return;
358    }}
359    if (list) fillList(list, rows);
360  }}
361
362  function render(structured) {{
363    var kind = document.body.dataset.panelKind;
364
365    if (kind === 'dashboard') {{
366      var byId = (structured && structured.panels) || {{}};
367      document.querySelectorAll('[data-child]').forEach(function (card) {{
368        var rows = byId[card.dataset.child];
369        renderChild(card, Array.isArray(rows) ? rows : []);
370      }});
371      return;
372    }}
373
374    var rows = [];
375    if (structured && Array.isArray(structured.rows)) rows = structured.rows;
376    else if (Array.isArray(structured)) rows = structured;
377
378    var empty = document.getElementById('dg-empty');
379    if (empty) empty.style.display = rows.length ? 'none' : '';
380
381    if (kind === 'metric' || kind === 'gauge') {{
382      var v = lastValue(rows);
383      var el = document.getElementById('dg-value');
384      if (el) el.textContent = (v === null || v === undefined) ? '—' : v;
385      var fill = document.getElementById('dg-fill');
386      if (fill && typeof v === 'number') {{
387        var lo = Number(document.body.dataset.min || 0);
388        var hi = Number(document.body.dataset.max || 100);
389        var f = hi === lo ? 0 : Math.max(0, Math.min(1, (v - lo) / (hi - lo)));
390        fill.style.width = (f * 100) + '%';
391      }}
392      return;
393    }}
394
395    var tbody = document.getElementById('dg-rows');
396    if (tbody) {{
397      tbody.textContent = '';
398      rows.slice(0, 200).forEach(function (row) {{
399        var tr = document.createElement('tr');
400        (Array.isArray(row) ? row : [row]).forEach(function (cell) {{
401          var td = document.createElement('td');
402          td.textContent = cell === null || cell === undefined ? '' : String(cell);
403          tr.appendChild(td);
404        }});
405        tbody.appendChild(tr);
406      }});
407      return;
408    }}
409
410    var series = document.getElementById('dg-series');
411    if (series) fillList(series, rows);
412  }}
413
414  window.addEventListener('message', function (event) {{
415    var msg = event.data;
416    if (!msg || typeof msg !== 'object') return;
417    if (msg.method === 'ui/notifications/tool-result') {{
418      render(msg.params && msg.params.structuredContent);
419    }}
420  }});
421{submit}
422
423  send('ui/initialize', {{}});
424  notify('ui/notifications/initialized', {{}});
425}})();
426</script>"#
427    )
428}
429
430/// Escape text for HTML text and attribute contexts.
431///
432/// Panel props are authored by whoever wrote the facts, and those facts may
433/// have been asserted by an agent. Treat every one of them as untrusted.
434fn escape(s: &str) -> String {
435    let mut out = String::with_capacity(s.len());
436    for ch in s.chars() {
437        match ch {
438            '&' => out.push_str("&amp;"),
439            '<' => out.push_str("&lt;"),
440            '>' => out.push_str("&gt;"),
441            '"' => out.push_str("&quot;"),
442            '\'' => out.push_str("&#39;"),
443            _ => out.push(ch),
444        }
445    }
446    out
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use datagrout_panels::PanelFacts;
453    use serde_json::json;
454
455    fn panel_from(facts: PanelFacts) -> Panel {
456        Panel::all_from_facts(&facts).into_iter().next().unwrap()
457    }
458
459    fn metric_panel() -> Panel {
460        panel_from(PanelFacts {
461            panels: vec![json!({"Id": "rms_now", "Kind": "metric", "Namespace": "app"})],
462            props: vec![json!({"Id": "rms_now", "Key": "title", "Value": "RMS"})],
463            ..Default::default()
464        })
465    }
466
467    #[test]
468    fn uri_is_well_formed_from_a_prolog_atom() {
469        assert_eq!(
470            ui_uri("my-app", "revenue_chart"),
471            "ui://my-app/revenue-chart"
472        );
473    }
474
475    #[test]
476    fn slug_collapses_runs_and_trims_edges() {
477        assert_eq!(slug("__a  b__"), "a-b");
478    }
479
480    #[test]
481    fn resource_carries_the_only_supported_mime_type() {
482        let res = to_ui_resource(&metric_panel(), &TranspileOptions::default());
483        assert_eq!(res.mime_type, "text/html;profile=mcp-app");
484        assert!(res.text.starts_with("<!DOCTYPE html>"));
485    }
486
487    #[test]
488    fn document_declares_the_handshake() {
489        let res = to_ui_resource(&metric_panel(), &TranspileOptions::default());
490        assert!(res.text.contains("ui/initialize"));
491        assert!(res.text.contains("ui/notifications/initialized"));
492        assert!(res.text.contains("ui/notifications/tool-result"));
493    }
494
495    #[test]
496    fn rows_are_not_baked_in_at_transpile_time() {
497        let mut facts = PanelFacts {
498            panels: vec![json!({"Id": "t", "Kind": "table", "Namespace": "ns"})],
499            ..Default::default()
500        };
501        facts.data = vec![json!({"Id": "t", "Rows": [["secret_value", 1]]})];
502
503        let res = to_ui_resource(&panel_from(facts), &TranspileOptions::default());
504        // A panel is re-derived on every read; freezing a snapshot into the
505        // document would contradict the model and leak stale data.
506        assert!(!res.text.contains("secret_value"));
507    }
508
509    #[test]
510    fn table_headers_come_from_the_columns_list() {
511        let facts = PanelFacts {
512            panels: vec![json!({"Id": "t", "Kind": "table", "Namespace": "ns"})],
513            props: vec![json!({"Id": "t", "Key": "columns", "Value": ["Invoice", "Days & Co"]})],
514            ..Default::default()
515        };
516        let res = to_ui_resource(&panel_from(facts), &TranspileOptions::default());
517        assert!(res.text.contains("<th>Invoice</th>"));
518        assert!(res.text.contains("<th>Days &amp; Co</th>"));
519    }
520
521    #[test]
522    fn a_dashboard_emits_one_card_per_child_and_reads_rows_by_id() {
523        let facts = PanelFacts {
524            panels: vec![
525                json!({"Id": "board", "Kind": "dashboard", "Namespace": "ns"}),
526                json!({"Id": "total", "Kind": "metric", "Namespace": "ns"}),
527                json!({"Id": "mix", "Kind": "bar_chart", "Namespace": "ns"}),
528            ],
529            props: vec![
530                json!({"Id": "board", "Key": "title", "Value": "Pulse"}),
531                json!({"Id": "total", "Key": "parent", "Value": "board"}),
532                json!({"Id": "total", "Key": "title", "Value": "Total"}),
533                json!({"Id": "mix", "Key": "parent", "Value": "board"}),
534            ],
535            ..Default::default()
536        };
537        let res = to_ui_resource(&panel_from(facts), &TranspileOptions::default());
538        assert!(res.text.contains(r#"data-child="total""#));
539        assert!(res.text.contains(r#"data-child="mix""#));
540        assert!(res.text.contains(r#"data-kind="metric""#));
541        assert!(res.text.contains("<h2>Total</h2>"));
542        // The runtime resolves each child's rows from structuredContent.panels.
543        assert!(res.text.contains("structured.panels"));
544    }
545
546    #[test]
547    fn tool_meta_uses_the_nested_key_not_the_deprecated_flat_one() {
548        let meta = tool_meta("ui://my-app/rms-now", true);
549        assert_eq!(meta["ui"]["resourceUri"], json!("ui://my-app/rms-now"));
550        assert_eq!(meta["ui"]["visibility"], json!(["model", "app"]));
551        assert!(meta.get("ui/resourceUri").is_none());
552    }
553
554    #[test]
555    fn app_only_visibility_omits_the_model() {
556        let meta = tool_meta("ui://my-app/x", false);
557        assert_eq!(meta["ui"]["visibility"], json!(["app"]));
558    }
559
560    #[test]
561    fn form_panels_are_interactive_even_when_not_requested() {
562        let facts = PanelFacts {
563            panels: vec![
564                json!({"Id": "f", "Kind": "form", "Namespace": "ns"}),
565                json!({"Id": "go", "Kind": "button", "Namespace": "ns"}),
566            ],
567            props: vec![
568                json!({"Id": "go", "Key": "parent", "Value": "f"}),
569                json!({"Id": "go", "Key": "label", "Value": "Run"}),
570            ],
571            ..Default::default()
572        };
573        let res = to_ui_resource(&panel_from(facts), &TranspileOptions::default());
574        assert!(res.text.contains("data-dg-submit"));
575        assert!(res.text.contains("ui/message"));
576        assert!(res.text.contains("querySelectorAll('#dg-form [id]')"));
577    }
578
579    #[test]
580    fn display_panels_emit_no_callback_path() {
581        let res = to_ui_resource(&metric_panel(), &TranspileOptions::default());
582        assert!(!res.text.contains("ui/message"));
583    }
584
585    #[test]
586    fn panel_text_is_escaped() {
587        let facts = PanelFacts {
588            panels: vec![json!({"Id": "x", "Kind": "metric", "Namespace": "ns"})],
589            props: vec![
590                json!({"Id": "x", "Key": "title", "Value": "<img src=x onerror=alert(1)>"}),
591            ],
592            ..Default::default()
593        };
594        let res = to_ui_resource(&panel_from(facts), &TranspileOptions::default());
595        assert!(!res.text.contains("<img src=x"));
596        assert!(res.text.contains("&lt;img"));
597    }
598
599    #[test]
600    fn resource_json_nests_meta_under_ui() {
601        let res = to_ui_resource(&metric_panel(), &TranspileOptions::default());
602        let json = res.to_resource_json();
603        assert_eq!(json["mimeType"], json!(MCP_APP_MIME));
604        assert!(json["_meta"]["ui"]["prefersBorder"].is_boolean());
605    }
606}