Skip to main content

agent_block_mcp/
lua_json.rs

1//! Lua ↔ JSON value bridge.
2//!
3//! Moved from `src/bridge/mod.rs` during the 4-crate split so that the MCP
4//! handler can reach these conversions without depending on `agent-block-core`.
5//! `agent-block-core::bridge` re-exports them for the other bridge modules
6//! (llm / mesh / mcp.lua), preserving the historical `crate::bridge::*` API.
7//!
8//! # The empty table, and how a value says which kind it is
9//!
10//! Lua has one table type, so `{}` is an empty array and an empty object
11//! at once and nothing in the value itself tells the two apart.  An
12//! untagged empty table is read here as an object, which is what this
13//! bridge has always done — but a producer that means the other one has a
14//! way to say so, because a boundary that cannot express `[]` pushes the
15//! shapes that need one into inventing content that was never there.
16//!
17//! A table declares itself an array by carrying a metatable that says so:
18//!
19//! * `__jsontype = "array"`, the tag Lua writes for itself
20//!   (`setmetatable({}, { __jsontype = "array" })`) and the convention the
21//!   Lua JSON libraries already use, or
22//! * mlua's own array metatable, so values its serde bridge produced are
23//!   read the same way rather than by a second rule.
24//!
25//! A tagged table is encoded from its sequence part, which is mlua's rule
26//! for the same tag.  [`json_to_lua`] tags the empty arrays it builds, so
27//! `[]` comes back as `[]` instead of turning into `{}` on the way home;
28//! a non-empty array needs no tag — a sequence is already unambiguous —
29//! and is left as it was, so no existing value changes shape.
30
31use mlua::prelude::*;
32
33/// Metatable field a table carries to declare itself a JSON array.
34pub const ARRAY_METAFIELD: &str = "__jsontype";
35/// The value of [`ARRAY_METAFIELD`] that means "array".
36pub const ARRAY_METAFIELD_VALUE: &str = "array";
37/// Where the shared array metatable is parked, once per Lua state.
38const ARRAY_METATABLE_REGISTRY_KEY: &str = "agent_block.lua_json.array_metatable";
39
40/// The metatable that marks a Lua table as a JSON array.
41///
42/// One table per Lua state, so every empty array [`json_to_lua`] builds
43/// shares it and a caller can compare against it.  Plain (`__metatable` is
44/// not set): the tag is a fact about the value, not a lock on it, and Lua
45/// code that wants to give the table a metatable of its own still can.
46pub fn array_metatable(lua: &Lua) -> LuaResult<LuaTable> {
47    if let Ok(LuaValue::Table(existing)) =
48        lua.named_registry_value::<LuaValue>(ARRAY_METATABLE_REGISTRY_KEY)
49    {
50        return Ok(existing);
51    }
52    let mt = lua.create_table()?;
53    mt.raw_set(ARRAY_METAFIELD, ARRAY_METAFIELD_VALUE)?;
54    lua.set_named_registry_value(ARRAY_METATABLE_REGISTRY_KEY, &mt)?;
55    Ok(mt)
56}
57
58/// Whether `table` declares itself an array.
59///
60/// Read raw: the tag is a plain field of the metatable, and an `__index`
61/// on it is the table's own business rather than a source of array-ness.
62fn is_tagged_array(lua: &Lua, table: &LuaTable) -> LuaResult<bool> {
63    let Some(mt) = table.metatable() else {
64        return Ok(false);
65    };
66    if let LuaValue::String(kind) = mt.raw_get::<LuaValue>(ARRAY_METAFIELD)? {
67        if &*kind.to_str()? == ARRAY_METAFIELD_VALUE {
68            return Ok(true);
69        }
70    }
71    // The same question asked of a value mlua's serde bridge built.
72    Ok(mt == lua.array_metatable())
73}
74
75/// Convert a Lua value to a serde_json::Value.
76///
77/// Round-trips with `json_to_lua` and `std.json.encode` (mlua-batteries).
78/// Lua `nil` maps to JSON `null`.  Unsupported types (functions, userdata
79/// other than `null`) yield an error so that callers do not silently emit
80/// malformed JSON.
81///
82/// An empty table becomes `{}` unless it is tagged as an array (see the
83/// module docs), in which case it becomes `[]`.
84pub fn lua_to_json(lua: &Lua, val: LuaValue) -> LuaResult<serde_json::Value> {
85    lua_to_json_inner(lua, &val, 0)
86}
87
88fn lua_to_json_inner(lua: &Lua, val: &LuaValue, depth: usize) -> LuaResult<serde_json::Value> {
89    const MAX_DEPTH: usize = 128;
90    if depth > MAX_DEPTH {
91        return Err(LuaError::external(format!(
92            "Lua table nesting too deep for JSON (limit: {MAX_DEPTH})"
93        )));
94    }
95    match val {
96        LuaValue::Nil => Ok(serde_json::Value::Null),
97        // mlua serde uses LightUserData(null_ptr) for JSON null.  Treat it
98        // the same as Nil so values produced by `json_to_lua` round-trip.
99        LuaValue::LightUserData(u) if u.0.is_null() => Ok(serde_json::Value::Null),
100        LuaValue::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
101        LuaValue::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
102        LuaValue::Number(n) => serde_json::Number::from_f64(*n)
103            .map(serde_json::Value::Number)
104            .ok_or_else(|| LuaError::external(format!("cannot convert {n} to JSON number"))),
105        LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
106        LuaValue::Table(t) => {
107            let len = t.raw_len();
108            if len > 0 || is_tagged_array(lua, t)? {
109                let mut arr = Vec::with_capacity(len);
110                for i in 1..=len {
111                    let v: LuaValue = t.raw_get(i)?;
112                    arr.push(lua_to_json_inner(lua, &v, depth + 1)?);
113                }
114                Ok(serde_json::Value::Array(arr))
115            } else {
116                let mut map = serde_json::Map::new();
117                for pair in t.clone().pairs::<LuaValue, LuaValue>() {
118                    let (k, v) = pair?;
119                    let key = match k {
120                        LuaValue::String(s) => s.to_str()?.to_string(),
121                        LuaValue::Integer(i) => i.to_string(),
122                        LuaValue::Number(n) => n.to_string(),
123                        other => {
124                            return Err(LuaError::external(format!(
125                                "unsupported table key type for JSON: {}",
126                                other.type_name()
127                            )));
128                        }
129                    };
130                    map.insert(key, lua_to_json_inner(lua, &v, depth + 1)?);
131                }
132                Ok(serde_json::Value::Object(map))
133            }
134        }
135        other => Err(LuaError::external(format!(
136            "unsupported type for JSON conversion: {}",
137            other.type_name()
138        ))),
139    }
140}
141
142/// Convert a serde_json::Value to a Lua value.
143///
144/// JSON `null` maps to the `LightUserData(null_ptr)` sentinel
145/// (`mlua::Value::NULL`), which is the same representation `lua_to_json`
146/// accepts on the way out — so the round-trip is symmetric.  Using the
147/// sentinel rather than Lua `nil` means JSON `null` values survive being
148/// placed into Lua tables (tables cannot hold `nil`), so SQL NULL columns
149/// and MCP/LLM JSON payloads do not lose the distinction between "null"
150/// and "absent".  Agents can compare a value against the exposed
151/// `std.sql.null` constant to detect it.
152///
153/// Note: this differs from mlua-batteries' `std.json.decode`, which keeps
154/// the Lua-idiomatic "null → nil" lowering for `json.decode` itself.  Our
155/// bridge paths (sql / kv / mcp / mesh / llm) prefer round-trip fidelity.
156///
157/// An empty array comes back tagged (see the module docs) so that it is
158/// still an array on the way back through [`lua_to_json`]; every other
159/// value is built exactly as it was before the tag existed.
160pub fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
161    json_to_lua_inner(lua, &val, 0)
162}
163
164fn json_to_lua_inner(lua: &Lua, val: &serde_json::Value, depth: usize) -> LuaResult<LuaValue> {
165    const MAX_DEPTH: usize = 128;
166    if depth > MAX_DEPTH {
167        return Err(LuaError::external(format!(
168            "JSON nesting too deep (limit: {MAX_DEPTH})"
169        )));
170    }
171    match val {
172        serde_json::Value::Null => Ok(LuaValue::NULL),
173        serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
174        serde_json::Value::Number(n) => {
175            if let Some(i) = n.as_i64() {
176                Ok(LuaValue::Integer(i))
177            } else if let Some(f) = n.as_f64() {
178                Ok(LuaValue::Number(f))
179            } else {
180                Err(LuaError::external(format!(
181                    "JSON number {n} is not representable as i64 or f64"
182                )))
183            }
184        }
185        serde_json::Value::String(s) => lua.create_string(s).map(LuaValue::String),
186        serde_json::Value::Array(arr) => {
187            let table = lua.create_table()?;
188            for (i, v) in arr.iter().enumerate() {
189                table.set(i + 1, json_to_lua_inner(lua, v, depth + 1)?)?;
190            }
191            // Only the empty one needs saying: a table with a sequence in
192            // it already reads as an array, and tagging it would change a
193            // value that was fine as it was.
194            if arr.is_empty() {
195                table.set_metatable(Some(array_metatable(lua)?))?;
196            }
197            Ok(LuaValue::Table(table))
198        }
199        serde_json::Value::Object(map) => {
200            let table = lua.create_table()?;
201            for (k, v) in map {
202                table.set(k.as_str(), json_to_lua_inner(lua, v, depth + 1)?)?;
203            }
204            Ok(LuaValue::Table(table))
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use serde_json::json;
213
214    /// `value` after a trip into Lua and back.
215    fn round_trip(lua: &Lua, value: serde_json::Value) -> serde_json::Value {
216        let in_lua = json_to_lua(lua, value.clone()).expect("into Lua");
217        lua_to_json(lua, in_lua).unwrap_or_else(|e| panic!("{value}: {e}"))
218    }
219
220    /// The value a Lua expression evaluates to, as JSON.
221    fn encode(lua: &Lua, chunk: &str) -> serde_json::Value {
222        let value: LuaValue = lua
223            .load(chunk)
224            .eval()
225            .unwrap_or_else(|e| panic!("{chunk}: {e}"));
226        lua_to_json(lua, value).unwrap_or_else(|e| panic!("{chunk}: {e}"))
227    }
228
229    /// The two empty shapes are the ones a single Lua table cannot tell
230    /// apart on its own, so they are what the tag is for: each comes back
231    /// as itself, including where they are nested inside one another.
232    #[test]
233    fn the_two_empty_shapes_survive_a_round_trip() {
234        let lua = Lua::new();
235        for value in [
236            json!([]),
237            json!({}),
238            json!({ "content": [], "usage": {} }),
239            json!([[], {}, [[]]]),
240            json!({ "a": { "b": [] }, "c": [{}] }),
241            json!({ "content": [{ "type": "text", "text": "hi" }] }),
242        ] {
243            assert_eq!(round_trip(&lua, value.clone()), value, "{value}");
244        }
245    }
246
247    /// The tag is the only thing that separates them: an untagged empty
248    /// table is still an object, which is what every caller that never
249    /// heard of the tag keeps getting.
250    #[test]
251    fn an_untagged_empty_table_is_still_an_object() {
252        let lua = Lua::new();
253        assert_eq!(encode(&lua, "return {}"), json!({}));
254        assert_eq!(encode(&lua, "return { a = {} }"), json!({ "a": {} }));
255    }
256
257    /// A table Lua tagged for itself is read as an array, and so is one
258    /// mlua's own serde bridge tagged — two ways of saying it, one answer.
259    #[test]
260    fn a_tagged_empty_table_is_an_array() {
261        let lua = Lua::new();
262        assert_eq!(
263            encode(&lua, r#"return setmetatable({}, { __jsontype = "array" })"#),
264            json!([])
265        );
266
267        lua.globals()
268            .set("mlua_array_mt", lua.array_metatable())
269            .expect("expose mlua's array metatable");
270        assert_eq!(
271            encode(&lua, "return setmetatable({}, mlua_array_mt)"),
272            json!([])
273        );
274
275        // A tag that says something else is not this tag.
276        assert_eq!(
277            encode(
278                &lua,
279                r#"return setmetatable({}, { __jsontype = "object" })"#
280            ),
281            json!({})
282        );
283        assert_eq!(
284            encode(&lua, r#"return setmetatable({}, { __index = {} })"#),
285            json!({})
286        );
287    }
288
289    /// The tag names the shape, it does not lock the table: what comes back
290    /// from JSON can still be given a metatable of its own, and reading its
291    /// metatable answers rather than refusing.
292    #[test]
293    fn the_tag_leaves_the_table_usable() {
294        let lua = Lua::new();
295        lua.globals()
296            .set("empty", json_to_lua(&lua, json!([])).expect("into Lua"))
297            .expect("set");
298        lua.load(
299            r#"
300            assert(#empty == 0, "a tagged empty array is still empty")
301            assert(next(empty) == nil, "the tag must not put a field in the table")
302            assert(getmetatable(empty).__jsontype == "array", "the tag must be readable")
303            assert(pcall(setmetatable, empty, {}), "the tag must not protect the table")
304        "#,
305        )
306        .exec()
307        .expect("tagged empty array chunk");
308    }
309
310    /// One metatable per state, so every empty array a run produces carries
311    /// the same tag and a caller can compare against it.
312    #[test]
313    fn every_empty_array_shares_one_metatable() {
314        let lua = Lua::new();
315        let first = json_to_lua(&lua, json!([])).expect("into Lua");
316        let second = json_to_lua(&lua, json!({ "a": [] })).expect("into Lua");
317        lua.globals().set("first", first).expect("set");
318        lua.globals().set("second", second).expect("set");
319        lua.load(
320            r#"
321            assert(getmetatable(first) == getmetatable(second.a), "two array tags in one state")
322            assert(getmetatable(first) ~= nil)
323        "#,
324        )
325        .exec()
326        .expect("shared metatable chunk");
327    }
328
329    /// Whatever the tag says, a table with a sequence in it is an array,
330    /// and a tagged table is encoded from that sequence — mlua's rule for
331    /// the same tag, so one table does not mean two things.
332    #[test]
333    fn a_sequence_is_an_array_tag_or_no_tag() {
334        let lua = Lua::new();
335        assert_eq!(encode(&lua, "return { 1, 2, 3 }"), json!([1, 2, 3]));
336        assert_eq!(
337            encode(
338                &lua,
339                r#"return setmetatable({ 1, 2 }, { __jsontype = "array" })"#
340            ),
341            json!([1, 2])
342        );
343        assert_eq!(
344            encode(
345                &lua,
346                r#"return setmetatable({ a = 5 }, { __jsontype = "array" })"#
347            ),
348            json!([]),
349            "a tagged table is its sequence part"
350        );
351    }
352}