eval: auto-serialize non-JSON values in save

This commit is contained in:
Adrià Arrufat
2026-05-31 16:31:06 +02:00
parent fc15027100
commit 85facd2fc7
2 changed files with 48 additions and 8 deletions

View File

@@ -202,7 +202,7 @@ pub const Tool = enum {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." },
\\ "save": { "type": "string", "description": "Optional bridge-store key. The eval's return value is stored under this name and re-exposed as `lp.<name>` to subsequent evals. Objects and arrays are stored as JSON automatically; a returned value must be JSON-serializable." }
\\ "save": { "type": "string", "description": "Optional bridge-store key. The eval's return value is stored under this name and re-exposed as `lp.<name>` to subsequent evals. Objects, arrays, and strings are serialized automatically — no JSON.stringify needed." }
\\ },
\\ "required": ["script"]
\\}
@@ -966,7 +966,7 @@ fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R
// `let`/`const` from leaking; top-level `await`/`return` need the async IIFE.
const block_script = std.fmt.allocPrintSentinel(
arena,
"{{\n{s}\n}}",
"{{ {s}\n}}",
.{args.script},
0,
) catch return ToolError.OutOfMemory;
@@ -991,14 +991,17 @@ fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R
};
};
// Silence on save= success so stdout pipes stay clean.
// Silence on save= success so stdout pipes stay clean. Objects/arrays
// already render as JSON; a bare string (or other non-JSON text) is
// JSON-encoded so it round-trips to `lp.<name>`.
if (args.save) |name| {
bridgeStoreSet(app_allocator, &session.bridge_store, name, result.text) catch |err| switch (err) {
const json_value = if (std.json.validate(arena, result.text) catch false)
result.text
else
std.json.Stringify.valueAlloc(arena, result.text, .{}) catch return ToolError.OutOfMemory;
bridgeStoreSet(app_allocator, &session.bridge_store, name, json_value) catch |err| switch (err) {
error.OutOfMemory => return ToolError.OutOfMemory,
error.InvalidJson => return .{
.text = "save= requires the eval to return JSON; wrap with JSON.stringify(...)",
.is_error = true,
},
error.InvalidJson => unreachable,
};
result = .{ .text = "" };
}

View File

@@ -738,6 +738,43 @@ test "MCP - eval: save= value is readable via lp.<name> in next eval" {
} }, out.written());
}
test "MCP - eval: save= a bare string round-trips without JSON.stringify" {
defer testing.reset();
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage("about:blank", &out.writer);
defer server.deinit();
const save_msg =
\\{
\\ "jsonrpc": "2.0",
\\ "id": 1,
\\ "method": "tools/call",
\\ "params": {
\\ "name": "eval",
\\ "arguments": { "script": "return document.title || 'untitled';", "save": "title" }
\\ }
\\}
;
try router.handleMessage(server, testing.arena_allocator, save_msg);
out.clearRetainingCapacity();
const read_msg =
\\{
\\ "jsonrpc": "2.0",
\\ "id": 2,
\\ "method": "tools/call",
\\ "params": {
\\ "name": "eval",
\\ "arguments": { "script": "lp.title" }
\\ }
\\}
;
try router.handleMessage(server, testing.arena_allocator, read_msg);
try testing.expectJson(.{ .id = 2, .result = .{
.content = &.{.{ .type = "text", .text = "untitled" }},
} }, out.written());
}
test "MCP - eval: lp.* mutations auto-sync between evals" {
defer testing.reset();
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);