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}