diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 92477f87..a090c233 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -56,6 +56,10 @@ pub fn isFunction(self: Value) bool { return v8.v8__Value__IsFunction(self.handle); } +pub fn isNativeError(self: Value) bool { + return v8.v8__Value__IsNativeError(self.handle); +} + pub fn isNull(self: Value) bool { return v8.v8__Value__IsNull(self.handle); } diff --git a/src/browser/tools.zig b/src/browser/tools.zig index c7dd917b..d1ce2b7c 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -1076,22 +1076,32 @@ fn runEval(arena: std.mem.Allocator, page: *lp.Frame, script: [:0]const u8, fall } const settled = promise.result(); + const rejected = promise.state() == .rejected; // No-return async IIFE → undefined → silence, so pipes stay clean. - if (promise.state() == .fulfilled and settled.isUndefined()) return .{ .text = "" }; - const text = settled.toStringSliceWithAlloc(arena) catch |err| switch (err) { + if (!rejected and settled.isUndefined()) return .{ .text = "" }; + const text = (if (rejected) settled.toStringSliceWithAlloc(arena) else evalResultText(arena, settled)) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .text = try formatJsError(arena, &try_catch, err), .is_error = true }, }; - return .{ .text = text, .is_error = (promise.state() == .rejected) }; + return .{ .text = text, .is_error = rejected }; } - const text = js_result.toStringSliceWithAlloc(arena) catch |err| switch (err) { + const text = evalResultText(arena, js_result) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .text = try formatJsError(arena, &try_catch, err), .is_error = true }, }; return .{ .text = text }; } +/// Objects/arrays serialize as JSON so `return obj` prints data, not +/// `[object Object]`; errors and primitives keep their string form. +fn evalResultText(arena: std.mem.Allocator, value: lp.js.Value) ![]u8 { + if (value.isObject() and !value.isFunction() and !value.isNativeError()) { + return value.toJson(arena); + } + return value.toStringSliceWithAlloc(arena); +} + fn formatJsError(arena: std.mem.Allocator, try_catch: *lp.js.TryCatch, err: anyerror) error{OutOfMemory}![]const u8 { const caught = try_catch.caughtOrError(arena, err); var aw: std.Io.Writer.Allocating = .init(arena); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 6c3c836e..93e49509 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -587,6 +587,30 @@ test "MCP - eval: bare expression still returns its value" { } }, out.written()); } +test "MCP - eval: object return serializes as JSON" { + 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 msg = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 1, + \\ "method": "tools/call", + \\ "params": { + \\ "name": "eval", + \\ "arguments": { "script": "return { n: 42, items: [1, 2] };" } + \\ } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, msg); + + try testing.expectJson(.{ .id = 1, .result = .{ + .content = &.{.{ .type = "text", .text = "{\"n\":42,\"items\":[1,2]}" }}, + } }, out.written()); +} + test "MCP - eval: localStorage persists across navigations and is origin-scoped" { defer testing.reset(); var out: std.io.Writer.Allocating = .init(testing.arena_allocator);