Skip to main content

purview/
block.rs

1//! Semantic blocks (`Block`) and their field types — protocol §4.4 / Appendix
2//! B.
3//!
4//! `block.id` is a pure internal detail: the developer never names blocks. The
5//! library assigns a window-unique, monotonic, never-reused id (`b{n}`) when a
6//! block is inserted, and serializes it as `block.id` on the wire so the agent
7//! can reference blocks (conforms to §4.4 / §10.3).
8
9use serde::Serialize;
10use serde_json::Value;
11
12/// Convert any `Serialize` value into the protocol `any` value (internal; a
13/// conversion failure falls back to `Null` rather than panicking).
14pub(crate) fn to_value<T>(v: T) -> Value
15where
16    T: Serialize,
17{
18    serde_json::to_value(v).unwrap_or(Value::Null)
19}
20
21/// Severity level of a `notice` block (§4.4).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub enum Severity {
25    /// Informational.
26    Info,
27    /// Warning.
28    Warn,
29    /// Error.
30    Error,
31    /// Success.
32    Success,
33}
34
35/// A single key/value item inside a `fields` block.
36#[derive(Debug, Clone, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct Field {
39    /// Human-readable field label.
40    pub label: String,
41    /// Current value (any JSON).
42    pub value: Value,
43    /// `true` when the value has been redacted (§4.5).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub sensitive: Option<bool>,
46}
47
48impl Field {
49    /// Plain field; the value accepts any `Serialize`.
50    pub fn new(label: impl Into<String>, value: impl Serialize) -> Self {
51        Field {
52            label: label.into(),
53            value: to_value(value),
54            sensitive: None,
55        }
56    }
57
58    /// Sensitive field: the value is fixed to `••••••` and marked
59    /// `sensitive: true` (plaintext never reaches the agent context, §4.5).
60    pub fn sensitive(label: impl Into<String>) -> Self {
61        Field {
62            label: label.into(),
63            value: Value::String("••••••".into()),
64            sensitive: Some(true),
65        }
66    }
67
68    /// Custom redacted display (e.g. `••1234`), still marked `sensitive: true`.
69    pub fn masked(label: impl Into<String>, shown: impl Into<String>) -> Self {
70        Field {
71            label: label.into(),
72            value: Value::String(shown.into()),
73            sensitive: Some(true),
74        }
75    }
76}
77
78/// A `(label, value)` tuple can be used directly as a field; the value accepts
79/// any `Serialize`.
80impl<L, V> From<(L, V)> for Field
81where
82    L: Into<String>,
83    V: Serialize,
84{
85    fn from((label, value): (L, V)) -> Self {
86        Field::new(label, value)
87    }
88}
89
90/// One semantic block. The `id` is assigned by the library (constructors leave
91/// it empty; it is filled in when the block is inserted into a window).
92///
93/// Note: this enum **deliberately** uses internal tagging
94/// `#[serde(tag = "type")]` — this is the wire format mandated by MCP GUI
95/// Bridge protocol §4.4 (`{"type":"text","id":...,"text":...}`). It cannot use
96/// serde's default external tagging without violating the protocol and breaking
97/// agent interop. This is the single documented exception to the
98/// the repo's `docs/code-style.md` "no tag=\"type\"" rule (that rule targets
99/// WASM binary size and does not apply to this server crate).
100#[derive(Debug, Clone, Serialize)]
101#[serde(
102    tag = "type",
103    rename_all = "camelCase",
104    rename_all_fields = "camelCase"
105)]
106pub enum Block {
107    /// A paragraph of text.
108    Text {
109        /// Block id (library-assigned).
110        id: String,
111        /// The text content.
112        text: String,
113    },
114    /// A group of key/value fields.
115    Fields {
116        /// Block id (library-assigned).
117        id: String,
118        /// The field items.
119        items: Vec<Field>,
120    },
121    /// A table.
122    Table {
123        /// Block id (library-assigned).
124        id: String,
125        /// Column headers, left to right.
126        columns: Vec<String>,
127        /// Row data; `rows[i][j]` aligns with `columns[j]`.
128        rows: Vec<Vec<Value>>,
129        /// Offset of the first row when truncated (§9); defaults to absent (0).
130        #[serde(skip_serializing_if = "Option::is_none")]
131        offset: Option<u64>,
132        /// Total row count when truncated (§9).
133        #[serde(skip_serializing_if = "Option::is_none")]
134        total: Option<u64>,
135    },
136    /// A list.
137    List {
138        /// Block id (library-assigned).
139        id: String,
140        /// List items.
141        items: Vec<Value>,
142        /// `true` for an ordered list.
143        #[serde(skip_serializing_if = "Option::is_none")]
144        ordered: Option<bool>,
145        /// Offset of the first item when truncated (§9).
146        #[serde(skip_serializing_if = "Option::is_none")]
147        offset: Option<u64>,
148        /// Total item count when truncated (§9).
149        #[serde(skip_serializing_if = "Option::is_none")]
150        total: Option<u64>,
151    },
152    /// A fallback block carrying an arbitrary structured payload.
153    Custom {
154        /// Block id (library-assigned).
155        id: String,
156        /// Arbitrary payload.
157        payload: Value,
158    },
159    /// A status / notice block.
160    Notice {
161        /// Block id (library-assigned).
162        id: String,
163        /// Severity level.
164        severity: Severity,
165        /// Notice text.
166        text: String,
167    },
168    /// Read-side rich media: an image / screenshot / chart.
169    Media {
170        /// Block id (library-assigned).
171        id: String,
172        /// MIME type, e.g. `image/png`.
173        mime_type: String,
174        /// Where to fetch it: an `https://`, `file://`, or inline
175        /// `data:<mime>;base64,…` URL.
176        url: String,
177        /// Text description — the semantic fallback for the agent.
178        #[serde(skip_serializing_if = "Option::is_none")]
179        alt: Option<String>,
180    },
181}
182
183impl Block {
184    /// Build a `text` block.
185    pub fn text(text: impl Into<String>) -> Block {
186        Block::Text {
187            id: String::new(),
188            text: text.into(),
189        }
190    }
191
192    /// Build a `fields` block from any iterable of items convertible to
193    /// `Field`.
194    pub fn fields<I, F>(items: I) -> Block
195    where
196        I: IntoIterator<Item = F>,
197        F: Into<Field>,
198    {
199        Block::Fields {
200            id: String::new(),
201            items: items.into_iter().map(Into::into).collect(),
202        }
203    }
204
205    /// Build a `table` block. Cells accept any `Serialize`.
206    pub fn table<C, R, Row, Cell>(columns: C, rows: R) -> Block
207    where
208        C: IntoIterator<Item = Cell>,
209        Cell: Into<String>,
210        R: IntoIterator<Item = Row>,
211        Row: IntoIterator,
212        <Row as IntoIterator>::Item: Serialize,
213    {
214        Block::Table {
215            id: String::new(),
216            columns: columns.into_iter().map(Into::into).collect(),
217            rows: rows
218                .into_iter()
219                .map(|row| row.into_iter().map(to_value).collect())
220                .collect(),
221            offset: None,
222            total: None,
223        }
224    }
225
226    /// Build a truncated `table` block (§9): `rows` is the shown window,
227    /// starting at `offset`, out of `total` rows overall.
228    pub fn table_truncated<C, R, Row, Cell>(columns: C, rows: R, offset: u64, total: u64) -> Block
229    where
230        C: IntoIterator<Item = Cell>,
231        Cell: Into<String>,
232        R: IntoIterator<Item = Row>,
233        Row: IntoIterator,
234        <Row as IntoIterator>::Item: Serialize,
235    {
236        let mut block = Block::table(columns, rows);
237        if let Block::Table {
238            offset: o,
239            total: t,
240            ..
241        } = &mut block
242        {
243            *o = Some(offset);
244            *t = Some(total);
245        }
246        block
247    }
248
249    /// Build an unordered `list` block. Items accept any `Serialize`.
250    pub fn list<I, T>(items: I) -> Block
251    where
252        I: IntoIterator<Item = T>,
253        T: Serialize,
254    {
255        Block::List {
256            id: String::new(),
257            items: items.into_iter().map(to_value).collect(),
258            ordered: None,
259            offset: None,
260            total: None,
261        }
262    }
263
264    /// Build an ordered `list` block. Items accept any `Serialize`.
265    pub fn ordered_list<I, T>(items: I) -> Block
266    where
267        I: IntoIterator<Item = T>,
268        T: Serialize,
269    {
270        Block::List {
271            id: String::new(),
272            items: items.into_iter().map(to_value).collect(),
273            ordered: Some(true),
274            offset: None,
275            total: None,
276        }
277    }
278
279    /// Build a truncated `list` block (§9): `items` is the shown window,
280    /// starting at `offset`, out of `total` items overall. `ordered` selects an
281    /// ordered (`1. 2. 3.`) or unordered list.
282    pub fn list_truncated<I, T>(items: I, ordered: bool, offset: u64, total: u64) -> Block
283    where
284        I: IntoIterator<Item = T>,
285        T: Serialize,
286    {
287        Block::List {
288            id: String::new(),
289            items: items.into_iter().map(to_value).collect(),
290            ordered: ordered.then_some(true),
291            offset: Some(offset),
292            total: Some(total),
293        }
294    }
295
296    /// Build a `notice` block.
297    pub fn notice(severity: Severity, text: impl Into<String>) -> Block {
298        Block::Notice {
299            id: String::new(),
300            severity,
301            text: text.into(),
302        }
303    }
304
305    /// Build a `custom` block. The payload accepts any `Serialize`.
306    pub fn custom(payload: impl Serialize) -> Block {
307        Block::Custom {
308            id: String::new(),
309            payload: to_value(payload),
310        }
311    }
312
313    /// Build a `media` block (read-side rich media: image / screenshot /
314    /// chart). `url` may be `https://`, `file://`, or an inline
315    /// `data:<mime>;base64,…` URL; `alt` starts empty (set it via the handle).
316    pub fn media(mime_type: impl Into<String>, url: impl Into<String>) -> Block {
317        Block::Media {
318            id: String::new(),
319            mime_type: mime_type.into(),
320            url: url.into(),
321            alt: None,
322        }
323    }
324
325    /// The block's current id.
326    pub(crate) fn id(&self) -> &str {
327        match self {
328            Block::Text { id, .. }
329            | Block::Fields { id, .. }
330            | Block::Table { id, .. }
331            | Block::List { id, .. }
332            | Block::Custom { id, .. }
333            | Block::Notice { id, .. }
334            | Block::Media { id, .. } => id,
335        }
336    }
337
338    /// Set the block's id (internal: called on insert or replace).
339    pub(crate) fn set_id(&mut self, new_id: String) {
340        match self {
341            Block::Text { id, .. }
342            | Block::Fields { id, .. }
343            | Block::Table { id, .. }
344            | Block::List { id, .. }
345            | Block::Custom { id, .. }
346            | Block::Notice { id, .. }
347            | Block::Media { id, .. } => *id = new_id,
348        }
349    }
350}