Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ let package = Package(
dependencies: ["InstanceMCPCore"],
path: "Sources/oab-computer"
),
// Test helper only (not a product): a stand-in MCP stdio server for the managed Playwright tests.
.executableTarget(
name: "FakeMCPStdioServer",
path: "Tests/FakeMCPStdioServer"
),
.testTarget(
name: "InstanceMCPCoreTests",
// The executable is a dependency only so it is built beside the tests: one test runs it with the hidden
// permission-check argument.
dependencies: ["InstanceMCPCore", "oab-computer"],
dependencies: ["InstanceMCPCore", "oab-computer", "FakeMCPStdioServer"],
path: "Tests/InstanceMCPCoreTests"
),
]
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ Playwright's tools under its own `tools/list`, filtered by the connection's prof
window on this Mac's desktop, so Connect's Screens pane shows what the agent is doing.
- Upstream down → its tools are absent from `tools/list`; everything else works. The upstream's
`Mcp-Session-Id` is re-established automatically. Local tool names win on collision.
- **Managed Playwright (no listener).** Without `--upstream browser=…` the app can run an installed
Playwright MCP itself, as a child process over stdio: nothing listens on any port. It looks under
`~/Library/Application Support/oab-computer/playwright/` (`--playwright-root <dir>` changes it;
`install.json` there names the install). The child starts on first use, is restarted with a
backoff (1 s, 2 s, 4 s, up to 30 s) and is marked unavailable after 5 failures in 2 minutes. Its
stderr is kept in memory only and is never written to a log. With nothing installed, the browser
tools are not listed, a call says Playwright is not installed, and `sys_info` and the menu show
the same. This build has no installer for it yet.

## Download and install

Expand Down
16 changes: 15 additions & 1 deletion Sources/InstanceMCPCore/Browser/BrowserBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,28 @@ public struct BrowserBackendError: Error, Equatable, Sendable {
/// Events tier in Phase 2; the CDP tier stays reserved and is never selected by `auto` (R2.7).
public protocol BrowserBackend: Sendable {
var id: BrowserBackendID { get }
/// Cheap. Never prompts and never starts anything.
/// Never prompts. A managed backend may start its child here (and the first call after an
/// install does), so callers must not poll it; the router asks only the backends it needs.
func status() async -> BrowserBackendStatus
/// The last known state without starting or probing anything, for menus and `sys_info`.
/// `nil` means nothing is known.
func peekStatus() async -> BrowserBackendStatus?
/// `managed` or `external`, no URLs. `nil` for a backend with no such notion.
var mode: String? { get async }
/// Process name and uid of an external listener, names only.
var ownerSummary: String? { get async }
/// Descriptors of the tools this backend serves right now.
func tools() async -> [JSONValue]
/// A `tools/call` result, relayed as the backend produced it.
func call(_ name: String, arguments: JSONValue) async throws -> JSONValue
}

extension BrowserBackend {
public func peekStatus() async -> BrowserBackendStatus? { nil }
public var mode: String? { get async { nil } }
public var ownerSummary: String? { get async { nil } }
}

public enum BrowserPreference: String, Sendable, CaseIterable {
case auto, appleEvents, playwright
}
Expand Down
111 changes: 100 additions & 11 deletions Sources/InstanceMCPCore/Browser/BrowserRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct BrowserConnection: Sendable, Equatable {
/// Phase 1 fills only the Playwright slot; the Apple Events slot is left open.
public actor BrowserRouter {
public static let guardWindow: TimeInterval = 30 * 60
static let maxTracked = 1024
public static let notSetUp = "Browser tools are not set up. Open OpenAB Computer, then Browser tools."

private let appleEvents: (any BrowserBackend)?
Expand Down Expand Up @@ -40,27 +41,36 @@ public actor BrowserRouter {
id == .appleEvents ? appleEvents : playwright
}

/// Statuses are asked lazily, in the order the rules need them, because asking a managed
/// backend may start its child (R2, R2.1).
func select(_ conn: BrowserConnection) async -> Selection {
let pref = conn.override ?? store.load()
let aReady = await appleEvents?.status().isReady ?? false
let pReady = await playwright?.status().isReady ?? false
func ready(_ id: BrowserBackendID) async -> Bool { await backend(id)?.status().isReady ?? false }
switch pref {
case .appleEvents: return aReady ? .active(appleEvents!) : .none
case .playwright: return pReady ? .active(playwright!) : .none
case .appleEvents: return await ready(.appleEvents) ? .active(appleEvents!) : .none
case .playwright: return await ready(.playwright) ? .active(playwright!) : .none
case .auto:
if let l = last[conn.key], clock().timeIntervalSince(l.at) < Self.guardWindow {
let lastReady = l.id == .appleEvents ? aReady : pReady
if lastReady, let b = backend(l.id) { return .active(b) }
if await ready(l.id), let b = backend(l.id) { return .active(b) }
let other: BrowserBackendID = l.id == .appleEvents ? .playwright : .appleEvents
if (other == .appleEvents ? aReady : pReady) { return .guarded(other: other) }
return .none
return await ready(other) ? .guarded(other: other) : .none
}
if pReady { return .active(playwright!) }
if aReady { return .active(appleEvents!) }
if await ready(.playwright) { return .active(playwright!) }
if await ready(.appleEvents) { return .active(appleEvents!) }
return .none
}
}

private func prune() {
let now = clock()
last = last.filter { now.timeIntervalSince($0.value.at) < Self.guardWindow }
if last.count > Self.maxTracked {
for (k, _) in last.sorted(by: { $0.value.at < $1.value.at }).prefix(last.count - Self.maxTracked) { last[k] = nil }
}
}

var trackedConnections: Int { last.count }

/// The catalog names the active backend serves now. Empty with no ready backend.
public func tools(_ conn: BrowserConnection) async -> [JSONValue] {
guard case .active(let b) = await select(conn) else { return [] }
Expand All @@ -72,8 +82,14 @@ public actor BrowserRouter {

public func call(_ name: String, arguments: JSONValue, _ conn: BrowserConnection) async -> JSONValue {
let backend: any BrowserBackend
prune()
switch await select(conn) {
case .none: return Self.errorResult(Self.notSetUp, backend: nil)
case .none:
var text = Self.notSetUp
if let p = playwright, case .needsSetup("playwright_not_installed")? = await p.peekStatus() {
text += " Playwright is not installed."
}
return Self.errorResult(text, backend: nil)
case .guarded(let other):
return Self.errorResult("The browser backend used earlier in this conversation is no longer "
+ "ready. The \(other.rawValue) backend is ready but is not used automatically in the "
Expand Down Expand Up @@ -114,3 +130,76 @@ public actor BrowserRouter {
return .object(o)
}
}

// MARK: reporting (R1.6, R5.9)

/// A non-sensitive snapshot of the browser tiers: states and fixed classes, never a URL, tab
/// title or page text.
public struct BrowserReport: Equatable, Sendable {
public struct Entry: Equatable, Sendable {
public let id: BrowserBackendID
public let state: String // ready | needs_setup | unavailable
public let errorClass: String?
public let mode: String? // managed | external
public let ownerSummary: String?
}
public let preference: BrowserPreference
public let active: BrowserBackendID?
public let backends: [Entry]

/// One line for `sys_info` text.
public var line: String {
let parts = backends.map { e -> String in
let name = e.id == .playwright ? "playwright" : "apple events"
let m = e.mode.map { " (\($0))" } ?? ""
switch e.state {
case "ready": return "\(name)\(m) ready"
case "needs_setup" where e.errorClass == "playwright_not_installed": return "\(name) not installed"
default: return "\(name)\(m) \(e.state.replacingOccurrences(of: "_", with: " "))\(e.errorClass.map { " (\($0))" } ?? "")"
}
}
return "browser tools: " + (parts.isEmpty ? "none" : parts.joined(separator: ", "))
}

public var json: JSONValue {
[
"preference": .string(preference.rawValue),
"active": active.map { .string($0.rawValue) } ?? .null,
"backends": .array(backends.map { e in
var o: [String: JSONValue] = ["id": .string(e.id.rawValue), "state": .string(e.state)]
if let c = e.errorClass { o["class"] = .string(c) }
if let m = e.mode { o["mode"] = .string(m) }
if let w = e.ownerSummary { o["owner"] = .string(w) }
return .object(o)
}),
]
}
}

extension BrowserRouter {
/// Built from `peekStatus`, so reading it never starts a child or probes a listener. The
/// active backend is the one `auto` would pick from what is known.
public func report(_ conn: BrowserConnection? = nil) async -> BrowserReport {
let pref = conn?.override ?? store.load()
var entries: [BrowserReport.Entry] = []
var firstReady: BrowserBackendID?
for b in [playwright, appleEvents].compactMap({ $0 }) {
let st = await b.peekStatus() ?? .unavailable("not_checked")
let (state, cls): (String, String?)
switch st {
case .ready: (state, cls) = ("ready", nil); if firstReady == nil { firstReady = b.id }
case .needsSetup(let c): (state, cls) = ("needs_setup", c)
case .unavailable(let c): (state, cls) = ("unavailable", c)
}
entries.append(.init(id: b.id, state: state, errorClass: cls, mode: await b.mode, ownerSummary: await b.ownerSummary))
}
var active: BrowserBackendID?
switch pref {
case .auto: active = firstReady
case .appleEvents, .playwright:
let want: BrowserBackendID = pref == .appleEvents ? .appleEvents : .playwright
active = entries.first { $0.id == want && $0.state == "ready" }?.id
}
return BrowserReport(preference: pref, active: active, backends: entries)
}
}
16 changes: 16 additions & 0 deletions Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,20 @@ public enum BrowserUpstreamURL {
throw Invalid(description: "browser upstream host must be the literal 127.0.0.1 or ::1")
}
}

/// A validated loopback listener address: the family follows the literal host.
public struct Endpoint: Equatable, Sendable {
public enum Family: Equatable, Sendable { case v4, v6 }
public let family: Family
public let port: UInt16
public init(family: Family, port: UInt16) { self.family = family; self.port = port }
}

/// `validate`, then the family and a range-checked port (no port means 80).
public static func endpoint(_ url: URL) throws -> Endpoint {
try validate(url)
let p = url.port ?? 80
guard (1...65535).contains(p) else { throw Invalid(description: "browser upstream port must be 1-65535") }
return Endpoint(family: url.host == "::1" ? .v6 : .v4, port: UInt16(p))
}
}
71 changes: 60 additions & 11 deletions Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import Foundation

/// A failure from the external Playwright path. `errorClass` is one of the fixed strings of
/// R7.5.4 and never carries upstream-supplied text.
public struct ExternalPlaywrightError: Error, Equatable, Sendable, CustomStringConvertible {
/// A failure from a Playwright transport, external or managed. `errorClass` is one of the fixed
/// strings (R7.5.4 for the external path) and never carries upstream-supplied text.
public struct PlaywrightTransportError: Error, Equatable, Sendable, CustomStringConvertible {
public let errorClass: String
public init(errorClass: String) { self.errorClass = errorClass }
public var description: String { errorClass }
}
public typealias ExternalPlaywrightError = PlaywrightTransportError

/// What the Playwright backend needs from a transport. Phase 1 has the external HTTP client;
/// the managed stdio client arrives next.
public protocol PlaywrightTransport: Sendable {
func healthCheck() async throws
func listTools() async throws -> [JSONValue]
func callTool(_ name: String, arguments: JSONValue) async throws -> JSONValue
/// Stops anything the transport started. A no-op for an external listener.
func shutdown() async
/// True once a managed child failed too often and waits for Retry.
var isLatched: Bool { get async }
}

extension PlaywrightTransport {
public func shutdown() async {}
public var isLatched: Bool { get async { false } }
}

private final class RefuseRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
Expand All @@ -30,14 +41,31 @@ public actor ExternalPlaywrightClient: PlaywrightTransport {
private let session: URLSession
private var sessionID: String?

/// Health requests and the best-effort session DELETE (R2.1).
public static let healthTimeout: TimeInterval = 2
private let timeout: TimeInterval

public init(url: URL, timeout: TimeInterval = 90) {
self.url = url
self.timeout = timeout
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = timeout
self.session = URLSession(configuration: cfg, delegate: RefuseRedirects(), delegateQueue: nil)
}

public func healthCheck() async throws { try await initialize() }
/// With a session: a `ping` (no new `initialize`, so the session id is not replaced). Without
/// one, or when the upstream forgot it: one `initialize`. Always 2 s.
public func healthCheck() async throws {
guard sessionID != nil else { try await initialize(timeout: Self.healthTimeout); return }
let (status, body, _) = try await post(Self.message("ping", [:]), timeout: Self.healthTimeout)
if status == 400 || status == 404 {
if status == 400 { await closeSession() } else { sessionID = nil } // 404: already gone upstream
try await initialize(timeout: Self.healthTimeout)
return
}
try Self.check(status)
_ = try Self.result(body)
}

public func listTools() async throws -> [JSONValue] {
let r = try await rpc("tools/list", params: [:])
Expand All @@ -54,37 +82,58 @@ public actor ExternalPlaywrightClient: PlaywrightTransport {
if sessionID == nil { try await initialize() }
var (status, body, _) = try await post(Self.message(method, params))
if status == 400 || status == 404 {
sessionID = nil
if status == 400 { await closeSession() } else { sessionID = nil } // 404: already gone upstream
try await initialize()
(status, body, _) = try await post(Self.message(method, params))
}
try Self.check(status)
return try Self.result(body)
}

private func initialize() async throws {
private func initialize(timeout: TimeInterval? = nil) async throws {
if sessionID != nil { await closeSession() }
let (status, body, headers) = try await post(Self.message("initialize", [
"protocolVersion": "2025-06-18", "capabilities": [:],
"clientInfo": ["name": "oab-computer", "version": "browser"],
], id: 0))
], id: 0), timeout: timeout)
try Self.check(status)
_ = try Self.result(body)
sessionID = headers.first { $0.key.lowercased() == "mcp-session-id" }?.value
_ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"])
_ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"], timeout: timeout)
}

/// Best-effort `DELETE` of the current session; errors are ignored and the id is cleared.
private func closeSession() async {
guard let sid = sessionID else { return }
sessionID = nil
var req = URLRequest(url: url)
req.httpMethod = "DELETE"
req.timeoutInterval = Self.healthTimeout
req.setValue(hostHeader, forHTTPHeaderField: "Host")
req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id")
_ = try? await session.data(for: req)
}

var hostHeaderForTests: String { hostHeader }

/// No port, the same as the IPv4 form has always sent; the allowed-hosts list compares verbatim.
private var hostHeader: String {
let h = url.host ?? ""
return h.contains(":") ? "[\(h)]" : h
}

private static func message(_ method: String, _ params: JSONValue, id: Int = Int.random(in: 1...1_000_000)) -> JSONValue {
["jsonrpc": "2.0", "id": .number(Double(id)), "method": .string(method), "params": params]
}

private func post(_ msg: JSONValue) async throws -> (Int, Data, [String: String]) {
private func post(_ msg: JSONValue, timeout: TimeInterval? = nil) async throws -> (Int, Data, [String: String]) {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.httpBody = try JSONCoding.encoder.encode(msg)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
// Bare host: Playwright MCP's allowed-hosts list compares the header verbatim.
if let h = url.host { req.setValue(h, forHTTPHeaderField: "Host") }
req.timeoutInterval = timeout ?? self.timeout
req.setValue(hostHeader, forHTTPHeaderField: "Host")
if let sid = sessionID { req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") }
do {
let (data, resp) = try await session.data(for: req)
Expand Down
Loading
Loading