1#![forbid(unsafe_code)]
49
50use datagrout_panels::{Panel, PanelKind};
51use serde_json::{json, Value};
52
53pub const MCP_APP_MIME: &str = "text/html;profile=mcp-app";
55
56pub const UI_CAPABILITY: &str = "io.modelcontextprotocol/ui";
58
59#[derive(Debug, Clone)]
61pub struct UiResource {
62 pub uri: String,
64 pub mime_type: &'static str,
65 pub text: String,
67 pub meta: Value,
69}
70
71impl UiResource {
72 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
83pub struct TranspileOptions {
85 pub server: String,
87 pub interactive: bool,
92 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
106pub 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 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
127pub 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
140pub 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; 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
166fn 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 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 _ => r#"<ul id="dg-series"></ul><p class="empty" id="dg-empty">no data</p>"#.to_string(),
302 }
303}
304
305fn 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
430fn 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("&"),
439 '<' => out.push_str("<"),
440 '>' => out.push_str(">"),
441 '"' => out.push_str("""),
442 '\'' => out.push_str("'"),
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 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 & 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 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("<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}