mcp: add HTTP transport and multi-session support

Introduces an HTTP transport option to serve multipleagents from a
single process. Each connection is routed to its own isolated
browsing session using the `Mcp-Session-Id` header.

Also adds new session management tools (`session_new`, `session_list`,
`session_close`) and refactors the MCP server to support multiple V8
isolates with parking.
This commit is contained in:
Adrià Arrufat
2026-07-12 14:57:24 +02:00
parent ca927adff2
commit f66f0c191d
10 changed files with 771 additions and 55 deletions

View File

@@ -200,6 +200,28 @@ Add to your MCP configuration:
}
```
#### HTTP transport and independent sessions
For serving several agents from one process, start the MCP server over HTTP
instead of stdio by giving it a port (add `--host 0.0.0.0` to accept remote
connections):
```bash
lightpanda mcp --port 9223
```
Clients POST JSON-RPC to `http://host:9223/mcp`. Each connection is routed to
its own **browsing session** — its own page, cookies and memory — so agents no
longer clobber each other's page:
- A client that `initialize`s without an `Mcp-Session-Id` header is assigned a
fresh session; the id comes back in the response's `Mcp-Session-Id` header.
Send it on subsequent requests to stay on that session (**isolation**).
- Two agents that send the **same** `Mcp-Session-Id` share one browsing context
(**sharing** — e.g. a workflow where several agents work the same page).
- The `session_new`, `session_list` and `session_close` tools manage sessions
explicitly. Sending `DELETE /mcp` with an `Mcp-Session-Id` closes that session.
[Read full documentation](https://lightpanda.io/docs/open-source/guides/mcp-server)
A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpanda-io/agent-skill).

View File

@@ -241,6 +241,8 @@ const Commands = cli.Builder(.{
.{
.name = "mcp",
.options = .{
.{ .name = "port", .type = ?u16 },
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
.{ .name = "cdp_port", .type = ?u16 },
},
.shared_options = CommonOptions,

View File

@@ -7,7 +7,7 @@
\\ agent starts an interactive AI agent that can browse the web
\\ fetch fetches the specified URL
\\ help displays this message
\\ mcp starts an MCP (Model Context Protocol) server over stdio
\\ mcp starts an MCP (Model Context Protocol) server (stdio or HTTP)
\\ serve starts a WebSocket CDP server
\\ version displays the version of {0s}
\\
@@ -120,15 +120,28 @@
.mcp =
\\usage: {0s} mcp [OPTIONS] [COMMON_OPTIONS]
\\
\\Starts an MCP (Model Context Protocol) server over stdio.
\\Starts an MCP (Model Context Protocol) server. Defaults to stdio; pass
\\--port to serve over HTTP with an independent browsing session per client.
\\
\\options:
\\ --cdp-port <INT>
\\ Also run a CDP (WebSocket) server on this port. Cannot be
\\ combined with --port (they share one network listener).
\\ Defaults to disabled.
\\ --cookie <PATH>
\\ Path to a JSON file to load cookies from (read-only).
\\ Defaults to no cookie loading.
\\ --cookie-jar <PATH>
\\ Path to a JSON file to save cookies to on exit (write-only).
\\ Defaults to no cookie saving.
\\ --host <HOST>
\\ Host to bind when --port is set (e.g. 0.0.0.0 for remote access).
\\ Defaults to "127.0.0.1".
\\ --port <INT>
\\ Serve MCP over HTTP on this port instead of stdio. Clients POST
\\ JSON-RPC to /mcp; route requests to a session with the
\\ Mcp-Session-Id header.
\\ Defaults to stdio (no HTTP server).
,
.agent =
\\agent command

View File

@@ -207,6 +207,27 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
log.opts.format = .logfmt;
// --port serves MCP over HTTP instead of stdio. It and --cdp-port
// both need app.network's single listener, so they can't combine.
if (opts.port) |port| {
if (opts.cdp_port != null) {
log.fatal(.mcp, "port conflicts with cdp-port", .{ .hint = "both need the single network listener" });
return;
}
const address = std.net.Address.parseIp(opts.host, port) catch |err| {
log.fatal(.mcp, "invalid address", .{ .err = err, .host = opts.host, .port = port });
return;
};
const http_server = try lp.mcp.HttpServer.init(allocator, app);
defer http_server.deinit();
// Shutdown rides the already-registered Network.stop handler:
// a signal stops the accept loop, run() returns, deinit joins.
http_server.run(address) catch |err| {
log.fatal(.mcp, "mcp http error", .{ .err = err });
};
return;
}
var cdp_server: ?*lp.Server = null;
if (opts.cdp_port) |port| {
const address = std.net.Address.parseIp("127.0.0.1", port) catch |err| {

View File

@@ -4,6 +4,7 @@ pub const protocol = @import("mcp/protocol.zig");
pub const Version = protocol.Version;
pub const router = @import("mcp/router.zig");
pub const Server = @import("mcp/Server.zig");
pub const HttpServer = @import("mcp/HttpServer.zig");
test {
std.testing.refAllDecls(@This());

347
src/mcp/HttpServer.zig Normal file
View File

@@ -0,0 +1,347 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! HTTP (MCP "Streamable HTTP") transport for the browser-tools MCP server.
//!
//! Lets many clients drive one process, each on its own browsing session.
//! Threading rule: V8 isolates are thread-affine, so ALL browser work — every
//! session's Browser/Session — lives on a single worker thread that owns the
//! `Server`. Connection threads never touch a browser; they parse HTTP,
//! marshal a `Job` to the worker over a queue, block on its completion, then
//! write the response. Session routing follows the `Mcp-Session-Id` header:
//! an `initialize` without one mints a fresh session (isolation by default);
//! reusing an id joins that session (sharing on purpose).
const std = @import("std");
const lp = @import("lightpanda");
const App = @import("../App.zig");
const Server = @import("Server.zig");
const router = @import("router.zig");
const log = lp.log;
const posix = std.posix;
const HttpServer = @This();
const ns_per_ms = std.time.ns_per_ms;
/// Cap on a single JSON-RPC request body. Generous: agent tool payloads
/// (e.g. a `save` script) can be large, but this bounds a hostile client.
const max_request_bytes = 16 * 1024 * 1024;
/// One unit of work handed from a connection thread to the browser worker.
/// Allocated on the connection thread's stack — safe because that thread
/// blocks on `done` for the whole time the worker reads `body`/`session_id`
/// and writes `out`.
const Job = struct {
kind: Kind,
body: []const u8,
session_id: ?[]const u8,
/// Where the worker writes the response. The connection thread owns the
/// backing buffer and reads it back once `done` fires.
out: *std.Io.Writer,
/// Session id the worker actually routed to; echoed back as
/// `Mcp-Session-Id`. Stored in a fixed buffer (not a worker arena) so it
/// outlives the worker moving on to the next job.
assigned_buf: [128]u8 = undefined,
assigned_len: usize = 0,
done: std.Thread.ResetEvent = .{},
next: ?*Job = null,
const Kind = enum { rpc, close };
fn assigned(self: *const Job) []const u8 {
return self.assigned_buf[0..self.assigned_len];
}
fn setAssigned(self: *Job, id: []const u8) void {
const n = @min(id.len, self.assigned_buf.len);
@memcpy(self.assigned_buf[0..n], id[0..n]);
self.assigned_len = n;
}
};
const Queue = struct {
mutex: std.Thread.Mutex = .{},
cond: std.Thread.Condition = .{},
head: ?*Job = null,
tail: ?*Job = null,
fn push(self: *Queue, job: *Job) void {
self.mutex.lock();
defer self.mutex.unlock();
job.next = null;
if (self.tail) |t| t.next = job else self.head = job;
self.tail = job;
self.cond.signal();
}
/// Pop the next job, waiting at most `timeout_ms`. Returns null on timeout
/// (or spurious wakeup) so the worker can pump idle sessions and retry.
fn pop(self: *Queue, timeout_ms: u64) ?*Job {
self.mutex.lock();
defer self.mutex.unlock();
if (self.head == null) {
self.cond.timedWait(&self.mutex, timeout_ms * ns_per_ms) catch {};
}
const job = self.head orelse return null;
self.head = job.next;
if (self.head == null) self.tail = null;
return job;
}
};
allocator: std.mem.Allocator,
app: *App,
queue: Queue = .{},
stopping: std.atomic.Value(bool) = .init(false),
// The worker owns `server`; other threads must not touch it.
worker_thread: std.Thread = undefined,
worker_ready: std.Thread.ResetEvent = .{},
worker_ok: bool = false,
/// Create the server and start its browser worker thread. Returns once the
/// worker's default session is up (or errors if it failed to initialize).
pub fn init(allocator: std.mem.Allocator, app: *App) !*HttpServer {
const self = try allocator.create(HttpServer);
errdefer allocator.destroy(self);
self.* = .{
.allocator = allocator,
.app = app,
};
self.worker_thread = try std.Thread.spawn(.{}, worker, .{self});
self.worker_ready.wait();
if (!self.worker_ok) {
self.worker_thread.join();
return error.WorkerInitFailed;
}
return self;
}
pub fn deinit(self: *HttpServer) void {
self.stopping.store(true, .release);
self.queue.cond.signal(); // nudge the worker off its idle wait
self.worker_thread.join();
self.allocator.destroy(self);
}
/// Accept MCP-over-HTTP connections until the network loop is stopped
/// (e.g. by the signal handler). Reuses the shared accept infrastructure;
/// blocks the calling thread in `Network.run`.
pub fn run(self: *HttpServer, address: std.net.Address) !void {
var bound = address;
try self.app.network.bind(&bound, self, onAccept);
log.note(.mcp, "mcp http server running", .{ .address = bound });
self.app.network.run();
}
/// Network hands us a nonblocking accepted socket; each connection is served
/// by its own thread doing blocking IO, so we clear O_NONBLOCK first.
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
const self: *HttpServer = @ptrCast(@alignCast(ctx));
const flags = posix.fcntl(socket, posix.F.GETFL, 0) catch {
posix.close(socket);
return;
};
_ = posix.fcntl(socket, posix.F.SETFL, flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))) catch {
posix.close(socket);
return;
};
const thread = std.Thread.spawn(.{}, handleConn, .{ self, socket }) catch |err| {
log.warn(.mcp, "mcp spawn", .{ .err = err });
posix.close(socket);
return;
};
thread.detach();
}
fn worker(self: *HttpServer) void {
var placeholder: std.Io.Writer.Allocating = .init(self.allocator);
defer placeholder.deinit();
const server = Server.init(self.allocator, self.app, &placeholder.writer) catch |err| {
log.err(.mcp, "mcp http server init", .{ .err = err });
self.worker_ready.set();
return;
};
defer server.deinit();
server.enableIsolateParking();
self.worker_ok = true;
self.worker_ready.set();
var arena: std.heap.ArenaAllocator = .init(self.allocator);
defer arena.deinit();
while (!self.stopping.load(.acquire)) {
const wait_ms = server.idle();
const job = self.queue.pop(wait_ms) orelse continue;
_ = arena.reset(.retain_capacity);
process(server, arena.allocator(), job);
job.done.set();
}
}
fn process(server: *Server, arena: std.mem.Allocator, job: *Job) void {
server.transport.retarget(job.out);
if (job.kind == .close) {
if (job.session_id) |sid| _ = server.closeSession(sid);
return;
}
const chosen = resolveSession(server, arena, job) catch |err| {
log.err(.mcp, "mcp session routing", .{ .err = err });
return;
};
job.setAssigned(chosen);
router.handleMessage(server, arena, job.body) catch |err| {
log.err(.mcp, "mcp handle", .{ .err = err });
};
}
/// Decide which session a request targets and make it active. An explicit
/// `Mcp-Session-Id` wins; otherwise `initialize` mints a new session and
/// everything else falls back to the default.
fn resolveSession(server: *Server, arena: std.mem.Allocator, job: *Job) ![]const u8 {
if (job.session_id) |sid| {
if (sid.len > 0) {
_ = try server.useSession(sid);
return sid;
}
}
if (isInitialize(arena, job.body)) {
const sid = try server.nextSessionId(arena);
_ = try server.useSession(sid);
return sid;
}
_ = try server.useSession(null);
return Server.default_session_id;
}
fn isInitialize(arena: std.mem.Allocator, body: []const u8) bool {
const Peek = struct { method: ?[]const u8 = null };
const peek = std.json.parseFromSliceLeaky(Peek, arena, body, .{ .ignore_unknown_fields = true }) catch return false;
const method = peek.method orelse return false;
return std.mem.eql(u8, method, "initialize");
}
fn handleConn(self: *HttpServer, socket: posix.socket_t) void {
const stream: std.net.Stream = .{ .handle = socket };
defer stream.close();
var recv_buf: [16 * 1024]u8 = undefined;
var send_buf: [16 * 1024]u8 = undefined;
var stream_reader = stream.reader(&recv_buf);
var stream_writer = stream.writer(&send_buf);
var http_server = std.http.Server.init(stream_reader.interface(), &stream_writer.interface);
var arena: std.heap.ArenaAllocator = .init(self.allocator);
defer arena.deinit();
// Reused across requests (served serially), not reallocated per request.
var out: std.Io.Writer.Allocating = .init(self.allocator);
defer out.deinit();
while (!self.stopping.load(.acquire)) {
var request = http_server.receiveHead() catch return; // peer closed or bad head
_ = arena.reset(.retain_capacity);
out.clearRetainingCapacity();
self.serve(&out.writer, arena.allocator(), &request) catch return;
if (!request.head.keep_alive) return;
}
}
/// Handle one request: marshal it to the browser worker and write the reply.
/// MCP Streamable HTTP — POST carries a JSON-RPC message; DELETE closes the
/// session named by `Mcp-Session-Id`. std.http.Server owns the framing.
fn serve(self: *HttpServer, out: *std.Io.Writer, arena: std.mem.Allocator, request: *std.http.Server.Request) !void {
const method = request.head.method;
if (method != .POST and method != .DELETE) {
return request.respond("", .{ .status = .method_not_allowed, .keep_alive = false });
}
if (request.head.expect != null) {
return request.respond("", .{ .status = .expectation_failed, .keep_alive = false });
}
// Read the session header and keep_alive before the body reader
// invalidates the head's string memory.
const session_id = try sessionHeader(arena, request);
const keep_alive = request.head.keep_alive;
var body_buf: [8 * 1024]u8 = undefined;
const body = request.readerExpectNone(&body_buf).allocRemaining(arena, .limited(max_request_bytes)) catch {
return request.respond("", .{ .status = .payload_too_large, .keep_alive = false });
};
var job: Job = .{
.kind = if (method == .DELETE) .close else .rpc,
.body = body,
.session_id = session_id,
.out = out,
};
self.queue.push(&job);
job.done.wait();
const resp = out.buffered();
var headers: [2]std.http.Header = undefined;
var n: usize = 0;
headers[n] = .{ .name = "content-type", .value = "application/json" };
n += 1;
if (job.assigned_len > 0) {
headers[n] = .{ .name = "mcp-session-id", .value = job.assigned() };
n += 1;
}
return request.respond(resp, .{
// An empty body means a notification (or a close): 202, no content.
.status = if (resp.len == 0) .accepted else .ok,
.keep_alive = keep_alive,
.extra_headers = headers[0..n],
});
}
/// Duplicate the `Mcp-Session-Id` request header into `arena`, or null.
fn sessionHeader(arena: std.mem.Allocator, request: *std.http.Server.Request) !?[]const u8 {
var it = request.iterateHeaders();
while (it.next()) |header| {
if (std.ascii.eqlIgnoreCase(header.name, "mcp-session-id")) {
return try arena.dupe(u8, header.value);
}
}
return null;
}
test "HttpServer - initialize is detected for session minting" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
try std.testing.expect(isInitialize(aa, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"));
try std.testing.expect(!isInitialize(aa, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}"));
try std.testing.expect(!isInitialize(aa, "not json"));
}

View File

@@ -13,61 +13,194 @@ const CDPNode = @import("../cdp/Node.zig");
const Self = @This();
/// Session every un-scoped request lands on. Over stdio there is only ever
/// this one; the HTTP transport routes to it when a client sends no
/// `Mcp-Session-Id`.
pub const default_session_id = "default";
/// One isolated browsing context. Each owns its own V8 isolate (via
/// `Browser`), so two agents driving different sessions never touch the same
/// page. Heap-allocated and never moved after `init`: `Browser` registers
/// self-pointers (watchdog, http_client) that must stay stable.
pub const Session = struct {
id: []const u8,
browser: lp.Browser,
session: *lp.Session,
notification: *lp.Notification,
node_registry: CDPNode.Registry,
fn isDefault(self: *const Session) bool {
return std.mem.eql(u8, self.id, default_session_id);
}
};
allocator: std.mem.Allocator,
app: *App,
notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
node_registry: CDPNode.Registry,
sessions: std.StringHashMapUnmanaged(*Session) = .empty,
/// Monotonic counter backing auto-generated session ids (`s1`, `s2`, …).
session_seq: u32 = 0,
/// When several sessions (each its own V8 isolate) share one thread, V8's
/// "current isolate" is a per-thread stack, so an isolate must be *entered*
/// around any use of it and left un-entered otherwise. The HTTP transport
/// sets this; stdio (one isolate, permanently entered by `Env`) leaves it
/// false and keeps its historical behavior. See `enterIsolate`/`exitIsolate`.
park_isolates: bool = false,
/// The session the request currently being handled targets. Safe as a single
/// field because every request is dispatched on one thread, one at a time;
/// the transport sets it (via `useSession`) before each dispatch. Tools and
/// resources read it rather than threading a session through every call.
active_session: *Session = undefined,
transport: Transport,
pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self {
const notification = try lp.Notification.init(allocator);
errdefer notification.deinit();
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
self.* = .{
.allocator = allocator,
.app = app,
.browser = undefined,
.transport = .init(allocator, writer),
.notification = notification,
.session = undefined,
.node_registry = CDPNode.Registry.init(allocator),
};
errdefer self.transport.deinit();
try self.browser.init(app, .{}, null);
errdefer self.browser.deinit();
self.session = try self.browser.newSession(self.notification);
try self.session.enableConsoleCapture();
if (app.config.cookieFile()) |cookie_path| {
lp.cookies.loadFromFile(self.session, cookie_path);
}
self.active_session = try self.createSession(default_session_id);
return self;
}
pub fn deinit(self: *Self) void {
if (self.app.config.cookieJarFile()) |cookie_jar_path| {
lp.cookies.saveToFile(&self.session.cookie_jar, cookie_jar_path);
}
var it = self.sessions.valueIterator();
while (it.next()) |entry| self.destroySession(entry.*);
self.sessions.deinit(self.allocator);
self.node_registry.deinit();
self.transport.deinit();
self.browser.deinit();
self.notification.deinit();
self.allocator.destroy(self);
}
/// Create the session named `id`, or return the existing one. The `id` is
/// duped, so the caller keeps ownership of its slice.
pub fn createSession(self: *Self, id: []const u8) !*Session {
if (self.sessions.get(id)) |existing| return existing;
const owned_id = try self.allocator.dupe(u8, id);
errdefer self.allocator.free(owned_id);
const entry = try self.allocator.create(Session);
errdefer self.allocator.destroy(entry);
const notification = try lp.Notification.init(self.allocator);
errdefer notification.deinit();
entry.* = .{
.id = owned_id,
.browser = undefined,
.session = undefined,
.notification = notification,
.node_registry = CDPNode.Registry.init(self.allocator),
};
errdefer entry.node_registry.deinit();
try entry.browser.init(self.app, .{}, null);
errdefer entry.browser.deinit();
entry.session = try entry.browser.newSession(notification);
try entry.session.enableConsoleCapture();
// Only the default session is backed by the on-disk cookie file; named
// sessions start clean so agents stay isolated by default.
if (entry.isDefault()) {
if (self.app.config.cookieFile()) |cookie_path| {
lp.cookies.loadFromFile(entry.session, cookie_path);
}
}
try self.sessions.put(self.allocator, owned_id, entry);
// Browser.init left the isolate entered; park it (see park_isolates).
self.exitIsolate(entry);
return entry;
}
/// Switch to the multi-isolate discipline: park the default (which `Server.init`
/// left entered) and require every use to bracket with `enterIsolate`. The HTTP
/// transport calls this on its worker thread before serving anyone.
pub fn enableIsolateParking(self: *Self) void {
self.park_isolates = true;
self.exitIsolate(self.defaultSession());
}
/// Make `entry`'s isolate the current one for this thread. Must bracket any
/// use of its Browser/Session (dispatch, idle pumping, teardown). No-op under
/// stdio, where the single isolate is permanently current.
pub fn enterIsolate(self: *Self, entry: *Session) void {
if (self.park_isolates) entry.browser.env.isolate.enter();
}
pub fn exitIsolate(self: *Self, entry: *Session) void {
if (self.park_isolates) entry.browser.env.isolate.exit();
}
/// Tear down the session named `id`. Returns false if no such session, or if
/// it is the default (which lives for the whole process).
pub fn closeSession(self: *Self, id: []const u8) bool {
if (std.mem.eql(u8, id, default_session_id)) return false;
const entry = self.sessions.fetchRemove(id) orelse return false;
if (self.active_session == entry.value) self.active_session = self.defaultSession();
self.destroySession(entry.value);
return true;
}
fn destroySession(self: *Self, entry: *Session) void {
if (entry.isDefault()) {
if (self.app.config.cookieJarFile()) |cookie_jar_path| {
lp.cookies.saveToFile(&entry.session.cookie_jar, cookie_jar_path);
}
}
// Re-enter so `Browser.deinit`'s `Env.deinit` exit stays balanced against
// a parked isolate (and operates on the current one).
self.enterIsolate(entry);
entry.node_registry.deinit();
entry.browser.deinit();
entry.notification.deinit();
self.allocator.free(entry.id);
self.allocator.destroy(entry);
}
/// The session an un-scoped (stdio, or header-less HTTP) request targets.
pub fn defaultSession(self: *Self) *Session {
return self.sessions.get(default_session_id).?;
}
/// Point subsequent tool/resource dispatch at the session named `id`, creating
/// it on first use. A null or empty `id` selects the default.
pub fn useSession(self: *Self, id: ?[]const u8) !*Session {
const wanted = id orelse "";
self.active_session = if (wanted.len == 0) self.defaultSession() else try self.createSession(wanted);
return self.active_session;
}
/// A session id that is not currently in use, formatted into `arena`.
pub fn nextSessionId(self: *Self, arena: std.mem.Allocator) ![]const u8 {
while (true) {
self.session_seq += 1;
const candidate = try std.fmt.allocPrint(arena, "s{d}", .{self.session_seq});
if (!self.sessions.contains(candidate)) return candidate;
}
}
/// Pump every live session's pending transfers and return the shortest time
/// the caller may block before pumping again. See `Session.idleSlice`.
pub fn idle(self: *Self) u31 {
return self.session.idleSlice();
var wait: u31 = std.math.maxInt(u31);
var it = self.sessions.valueIterator();
while (it.next()) |entry| {
// Pumping may resume JS (e.g. a completed script fetch), so it needs
// the session's isolate current.
self.enterIsolate(entry.*);
wait = @min(wait, entry.*.session.idleSlice());
self.exitIsolate(entry.*);
}
return wait;
}
pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void {
@@ -96,6 +229,10 @@ pub fn handleToolList(self: *Self, arena: std.mem.Allocator, req: protocol.Reque
}
pub fn handleToolCall(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void {
// Dispatch runs page JS, so enter the target isolate around it.
const entry = self.active_session;
self.enterIsolate(entry);
defer self.exitIsolate(entry);
return tools.handleCall(self, arena, req);
}
@@ -104,6 +241,9 @@ pub fn handleResourceList(self: *Self, req: protocol.Request) !void {
}
pub fn handleResourceRead(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void {
const entry = self.active_session;
self.enterIsolate(entry);
defer self.exitIsolate(entry);
return resources.handleRead(self, arena, req);
}

View File

@@ -36,6 +36,13 @@ pub fn deinit(self: *Self) void {
self.aw.deinit();
}
/// Point subsequent responses at a different sink. The HTTP transport uses
/// this to capture each request's response into its own buffer; safe because
/// the browser worker retargets and writes on a single thread.
pub fn retarget(self: *Self, writer: *std.io.Writer) void {
self.writer = writer;
}
pub fn sendResponse(self: *Self, response: anytype) !void {
self.mutex.lock();
defer self.mutex.unlock();

View File

@@ -86,7 +86,7 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
return server.sendError(req_id, .InvalidRequest, "Resource not found");
};
const frame = server.session.currentFrame() orelse {
const frame = server.active_session.session.currentFrame() orelse {
return server.sendError(req_id, .FrameNotLoaded, "Page not loaded");
};

View File

@@ -36,12 +36,46 @@ const save_schema = browser_tools.minify(
\\}
);
const session_new_schema = browser_tools.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "name": { "type": "string", "description": "Optional id for the new session. Omit to get an auto-generated one. Reusing an existing id returns that session (a way to share one browsing context between agents)." }
\\ }
\\}
);
const session_id_schema = browser_tools.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "id": { "type": "string", "description": "The session id." }
\\ },
\\ "required": ["id"]
\\}
);
const extra_tools = [_]McpTool{
.{
.name = "save",
.description = "Save the session as a reusable Lightpanda agent script. You hold the conversation, so synthesize the `script` yourself — `const page = new Page(); await page.goto(url);` then call the builtins you used as tools (extract, click, fill, …) as methods on `page` with the same object arguments. Keep `$LP_*` placeholders; never inline a resolved secret.\n\n" ++ browser_tools.save_synthesis_prompt ++ "\n\n" ++ browser_tools.save_script_rules,
.inputSchema = save_schema,
},
.{
.name = "session_new",
.description = "Create a new isolated browser session (its own page, cookies and memory) and return its id. Use it to give a separate agent its own browsing context, or to obtain an id to share. Pass that id back as the `Mcp-Session-Id` header to route calls to it.",
.inputSchema = session_new_schema,
},
.{
.name = "session_list",
.description = "List the active browser sessions with their id and current URL. The `default` session always exists.",
.inputSchema = browser_tools.minify("{ \"type\": \"object\", \"properties\": {} }"),
},
.{
.name = "session_close",
.description = "Close a browser session, freeing its page and memory. The `default` session cannot be closed.",
.inputSchema = session_id_schema,
},
};
const all_tools = browser_tool_list ++ extra_tools;
@@ -49,6 +83,9 @@ const all_tools = browser_tool_list ++ extra_tools;
/// Tools that bypass the browser-tool dispatch and have their own handlers.
const ExtraTool = enum {
save,
session_new,
session_list,
session_close,
};
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -68,6 +105,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
if (std.meta.stringToEnum(ExtraTool, call_params.name)) |tool| {
return switch (tool) {
.save => handleSave(server, arena, id, call_params.arguments),
.session_new => handleSessionNew(server, arena, id, call_params.arguments),
.session_list => handleSessionList(server, arena, id),
.session_close => handleSessionClose(server, arena, id, call_params.arguments),
};
}
@@ -85,7 +125,8 @@ fn dispatchBrowserTool(
return server.sendError(id, .MethodNotFound, "Tool not found");
};
const result = browser_tools.call(arena, server.session, &server.node_registry, name, arguments) catch |err| {
const active = server.active_session;
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments) catch |err| {
// evaluate/extract surface failures in-band so the LLM can self-correct;
// other tools' operational failures are protocol-level.
if (surfacesErrorInBand(tool)) {
@@ -138,6 +179,72 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
try sendToolResultText(server, id, msg, false);
}
/// The session tools require the HTTP transport's parked-isolate discipline:
/// a second session means a second V8 isolate, only safe when isolates are
/// entered around use. Over stdio (one permanently-entered isolate) they are
/// all unsupported, kept uniform so clients see one consistent rule.
fn requireMultiSession(server: *Server, id: std.json.Value) !bool {
if (server.park_isolates) return true;
try sendToolResultText(server, id, "multiple sessions require the HTTP transport (start with --http-port)", true);
return false;
}
fn handleSessionNew(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
if (!try requireMultiSession(server, id)) return;
const Args = struct { name: ?[]const u8 = null };
const args = browser_tools.parseArgsOrDefault(Args, arena, arguments) catch {
return server.sendError(id, .InvalidParams, "expected { name?: string }");
};
const requested: ?[]const u8 = if (args.name) |n| (if (n.len > 0) n else null) else null;
const sid = requested orelse (server.nextSessionId(arena) catch
return sendErrorContent(server, id, "out of memory"));
_ = server.createSession(sid) catch |err|
return sendErrorContent(server, id, @errorName(err));
return sendToolResultFmt(server, arena, id, "session {s}", .{sid});
}
fn handleSessionList(server: *Server, arena: std.mem.Allocator, id: std.json.Value) !void {
if (!try requireMultiSession(server, id)) return;
const Entry = struct { id: []const u8, url: ?[]const u8 };
var list: std.ArrayList(Entry) = .empty;
var it = server.sessions.valueIterator();
while (it.next()) |entry| {
const url: ?[]const u8 = if (entry.*.session.currentFrame()) |frame| frame.url else null;
list.append(arena, .{ .id = entry.*.id, .url = url }) catch
return sendErrorContent(server, id, "out of memory");
}
const json = std.json.Stringify.valueAlloc(arena, list.items, .{ .emit_null_optional_fields = false }) catch
return sendErrorContent(server, id, "out of memory");
try sendToolResultText(server, id, json, false);
}
fn handleSessionClose(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
if (!try requireMultiSession(server, id)) return;
const Args = struct { id: []const u8 };
const args = browser_tools.parseArgs(Args, arena, arguments) catch {
return server.sendError(id, .InvalidParams, "expected { id: string }");
};
if (std.mem.eql(u8, args.id, Server.default_session_id)) {
return sendErrorContent(server, id, "the default session cannot be closed");
}
// Closing the session serving this very call would tear down the isolate
// mid-dispatch; require the client to be elsewhere first.
if (std.mem.eql(u8, args.id, server.active_session.id)) {
return sendErrorContent(server, id, "cannot close the session you are attached to");
}
if (!server.closeSession(args.id)) {
return sendErrorContent(server, id, "no such session");
}
return sendToolResultFmt(server, arena, id, "closed session {s}", .{args.id});
}
fn writeScript(path: []const u8, content: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{ .truncate = true });
defer file.close();
@@ -154,6 +261,12 @@ fn sendErrorContent(server: *Server, id: std.json.Value, msg: []const u8) !void
return sendToolResultText(server, id, msg, true);
}
fn sendToolResultFmt(server: *Server, arena: std.mem.Allocator, id: std.json.Value, comptime fmt: []const u8, args: anytype) !void {
const msg = std.fmt.allocPrint(arena, fmt, args) catch
return sendErrorContent(server, id, "out of memory");
return sendToolResultText(server, id, msg, false);
}
const router = @import("router.zig");
const testing = @import("../testing.zig");
@@ -861,11 +974,11 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
const frame = server.session.currentFrame().?;
const frame = server.active_session.session.currentFrame().?;
{
const btn = frame.document.getElementById("btn", frame).?.asNode();
const btn_id = (try server.node_registry.register(btn)).id;
const btn_id = (try server.active_session.node_registry.register(btn)).id;
var btn_id_buf: [12]u8 = undefined;
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
@@ -877,7 +990,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const inp = frame.document.getElementById("inp", frame).?.asNode();
const inp_id = (try server.node_registry.register(inp)).id;
const inp_id = (try server.active_session.node_registry.register(inp)).id;
var inp_id_buf: [12]u8 = undefined;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"value\":\"hello\"}}}" });
@@ -889,7 +1002,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const sel = frame.document.getElementById("sel", frame).?.asNode();
const sel_id = (try server.node_registry.register(sel)).id;
const sel_id = (try server.active_session.node_registry.register(sel)).id;
var sel_id_buf: [12]u8 = undefined;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"value\":\"opt2\"}}}" });
@@ -901,7 +1014,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const scrollbox = frame.document.getElementById("scrollbox", frame).?.asNode();
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
const scrollbox_id = (try server.active_session.node_registry.register(scrollbox)).id;
var scroll_id_buf: [12]u8 = undefined;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
@@ -912,7 +1025,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const el = frame.document.getElementById("hoverTarget", frame).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
const el_id = (try server.active_session.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" });
@@ -923,7 +1036,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const el = frame.document.getElementById("keyTarget", frame).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
const el_id = (try server.active_session.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" });
@@ -934,7 +1047,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const el = frame.document.getElementById("sel2", frame).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
const el_id = (try server.active_session.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" });
@@ -945,7 +1058,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const el = frame.document.getElementById("chk", frame).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
const el_id = (try server.active_session.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
@@ -956,7 +1069,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
{
const el = frame.document.getElementById("rad", frame).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
const el_id = (try server.active_session.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
@@ -999,10 +1112,10 @@ test "MCP - click that navigates clears node registry" {
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_nav.html", &out.writer);
defer server.deinit();
const before_frame = server.session.currentFrame().?;
const before_frame = server.active_session.session.currentFrame().?;
const link = before_frame.document.getElementById("navlink", before_frame).?.asNode();
const link_id = (try server.node_registry.register(link)).id;
try testing.expect(server.node_registry.lookup_by_id.contains(link_id));
const link_id = (try server.active_session.node_registry.register(link)).id;
try testing.expect(server.active_session.node_registry.lookup_by_id.contains(link_id));
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{link_id}) catch unreachable;
@@ -1013,8 +1126,8 @@ test "MCP - click that navigates clears node registry" {
});
try router.handleMessage(server, aa, click_msg);
try testing.expect(server.session.currentFrame().? != before_frame);
try testing.expect(!server.node_registry.lookup_by_id.contains(link_id));
try testing.expect(server.active_session.session.currentFrame().? != before_frame);
try testing.expect(!server.active_session.node_registry.lookup_by_id.contains(link_id));
}
test "MCP - Actions by selector: hover, selectOption, setChecked" {
@@ -1026,7 +1139,7 @@ test "MCP - Actions by selector: hover, selectOption, setChecked" {
defer server.deinit();
// Single-page test: reach straight into the live page.
const page = server.session.pages.items[0];
const page = server.active_session.session.pages.items[0];
{
const msg =
@@ -1291,8 +1404,8 @@ test "MCP - getCookies: defaults to current page, url filter, all flag" {
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_press_form.htm", &out.writer);
defer server.deinit();
try server.session.cookie_jar.populateFromResponse("http://localhost:9582", "session=abc; Path=/");
try server.session.cookie_jar.populateFromResponse("http://other.test/", "tracking=xyz; Path=/");
try server.active_session.session.cookie_jar.populateFromResponse("http://localhost:9582", "session=abc; Path=/");
try server.active_session.session.cookie_jar.populateFromResponse("http://other.test/", "tracking=xyz; Path=/");
const default_msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getCookies"}}
@@ -1331,7 +1444,7 @@ test "MCP - getCookies without a loaded page refuses instead of dumping the jar"
var server = try Server.init(testing.allocator, testing.test_app, &out.writer);
defer server.deinit();
try server.session.cookie_jar.populateFromResponse("http://example.com/", "session=abc; Path=/");
try server.active_session.session.cookie_jar.populateFromResponse("http://example.com/", "session=abc; Path=/");
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getCookies"}}
@@ -1358,14 +1471,64 @@ test "MCP - waitForState with bad state surfaces rich error" {
try testing.expect(std.mem.indexOf(u8, written, "isError\":true") != null);
}
test "MCP - sessions: new, list, attach isolation, close" {
defer testing.reset();
const aa = testing.arena_allocator;
var out: std.io.Writer.Allocating = .init(aa);
var server = try Server.init(testing.allocator, testing.test_app, &out.writer);
defer server.deinit();
// Session tools require the HTTP transport's parked-isolate discipline.
server.enableIsolateParking();
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_new","arguments":{"name":"a"}}}
);
try testing.expect(std.mem.indexOf(u8, out.written(), "session a") != null);
try testing.expect(server.sessions.contains("a"));
out.clearRetainingCapacity();
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list"}}
);
// The listing is JSON nested in the tool-result text, so its quotes are
// escaped (\"default\").
try testing.expect(std.mem.indexOf(u8, out.written(), "\\\"default\\\"") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "\\\"a\\\"") != null);
// Routing a request to "a" (as the Mcp-Session-Id header does) and loading
// a page there leaves the default untouched, proving the two are isolated.
_ = try server.useSession("a");
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"goto","arguments":{"url":"about:blank"}}}
);
try testing.expect(server.sessions.get("a").?.session.currentFrame() != null);
try testing.expect(server.defaultSession().session.currentFrame() == null);
// Route back to the default before closing "a" (the active session can't be closed).
_ = try server.useSession(null);
out.clearRetainingCapacity();
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"session_close","arguments":{"id":"default"}}}
);
try testing.expect(std.mem.indexOf(u8, out.written(), "cannot be closed") != null);
out.clearRetainingCapacity();
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"session_close","arguments":{"id":"a"}}}
);
try testing.expect(std.mem.indexOf(u8, out.written(), "closed session a") != null);
try testing.expect(!server.sessions.contains("a"));
}
fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server {
var server = try Server.init(testing.allocator, testing.test_app, writer);
errdefer server.deinit();
const page = try server.session.createPage();
const page = try server.active_session.session.createPage();
try page.navigate(url, .{});
var runner = server.session.runner(.{});
var runner = server.active_session.session.runner(.{});
try runner.waitForFrame(page.frame_id, 2000, .{ .until = .done });
return server;
}