Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/
grant is the identity; `Tailscale-User-Login` there would be pod-supplied.
- `--no-attach` disables the plane (`/attach` → 404).

### Browser tools for lent sessions (`--upstream`)

The sandbox has no path to any browser, so browser control is served **from this Mac**: with
`--upstream browser=http://127.0.0.1:8794/mcp` (deploy.sh adds it when the Playwright MCP
LaunchAgent from [`poc/pw-mcp`](poc/pw-mcp/README.md) is installed) the daemon re-serves
Playwright's tools under its own `tools/list`, filtered by the connection's profile:

- `owner` sees all 32 `browser_*` tools; `sandbox` sees the navigate / read / interact subset
(`ToolProfile.sandboxBrowserTools`) and **not** `browser_run_code_unsafe`, file upload / PDF,
network inspection, raw mouse-by-coordinate, dialogs, `browser_close`. New Playwright tools are
denied under sandbox until listed.
- `browser_navigate` + `browser_snapshot` returns the page as an accessibility tree — the first
video title on a channel page is one text line, no screenshot, no OCR. The browser is a real
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.

Verified 2026-09-26 from a lent pod session (sandbox): 16 `browser_*` tools listed, `run_code_unsafe`
unknown, navigate → snapshot on a YouTube channel returned the first video's title as text.

Verified 2026-09-26 end to end on macmini against the openab-pty runtime (PR #38): mint via
`admin_credential`, dial, the session shell's `$OPENAB_TOOLS_MCP_URL` listed
`sys_info screenshot mouse key osascript instance_status`, `exec` refused, `sys_info` answered,
Expand Down
6 changes: 4 additions & 2 deletions Sources/InstanceMCPCore/AttachManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,10 @@ public actor AttachManager {
You reached this Mac through OpenAB Connect: a human lent it to your sandbox session for a \
limited time and is likely watching the screen. This is the `sandbox` profile — there is no \
`exec` tool here (you already have a shell in your own session); drive the Mac through \
`screenshot`, `mouse`, `key` and `osascript`. If a tool starts failing with "not attached", \
the grant ended; ask the human to lend the Mac again.
`screenshot`, `mouse`, `key` and `osascript`, and — when `browser_*` tools are listed — through \
the browser directly: `browser_navigate` then `browser_snapshot` gives you the page as text, \
no screenshot needed. If a tool starts failing with "not attached", the grant ended; ask the \
human to lend the Mac again.
"""
}

Expand Down
36 changes: 33 additions & 3 deletions Sources/InstanceMCPCore/MCPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,39 @@ public struct MCPServer: Sendable {
public let instructions: String?
private let tools: [String: any Tool]
private let toolOrder: [String]
/// Loopback MCP servers whose tools are merged into `tools/list` (after the
/// local ones) and routed on `tools/call`. Resolved at request time, so an
/// upstream that is down simply contributes nothing. See `UpstreamMCP`.
public let upstreams: [UpstreamMCP]
/// Applied to upstream tool names at list/call time. Local tools are already
/// filtered by `scoped(to:)`; upstream lists are dynamic, so the filter has
/// to travel with the server.
public let upstreamFilter: ToolProfile?

public init(name: String, version: String, instructions: String? = nil, tools: [any Tool]) {
public init(name: String, version: String, instructions: String? = nil, tools: [any Tool],
upstreams: [UpstreamMCP] = [], upstreamFilter: ToolProfile? = nil) {
self.serverName = name
self.serverVersion = version
self.instructions = instructions
var map: [String: any Tool] = [:]
for t in tools { map[t.name] = t }
self.tools = map
self.toolOrder = tools.map(\.name)
self.upstreams = upstreams
self.upstreamFilter = upstreamFilter
}

/// Upstream tools visible under the current filter, as `Tool`s.
func upstreamTools() async -> [UpstreamTool] {
var out: [UpstreamTool] = []
for u in upstreams {
for d in await u.tools() {
let t = UpstreamTool(descriptorValue: d, upstream: u)
if upstreamFilter?.allows(t.name) ?? true { out.append(t) }
}
}
// Local names win on collision: an upstream cannot shadow `screenshot`.
return out.filter { tools[$0.name] == nil }
}

/// Tools in declaration order. Used by `scoped(to:)`.
Expand Down Expand Up @@ -86,13 +110,19 @@ public struct MCPServer: Sendable {
return [:]

case "tools/list":
return ["tools": .array(toolOrder.compactMap { tools[$0]?.descriptor })]
var list = toolOrder.compactMap { tools[$0]?.descriptor }
list += await upstreamTools().map(\.descriptor)
return ["tools": .array(list)]

case "tools/call":
guard let name = req.params?["name"]?.stringValue else {
throw JSONRPCError.invalidParams("missing tool name")
}
guard let tool = tools[name] else {
var resolved: (any Tool)? = tools[name]
if resolved == nil, !upstreams.isEmpty {
resolved = await upstreamTools().first { $0.name == name }
}
guard let tool = resolved else {
throw JSONRPCError.invalidParams("unknown tool: \(name)")
}
let args = req.params?["arguments"] ?? [:]
Expand Down
4 changes: 4 additions & 0 deletions Sources/InstanceMCPCore/Tool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public struct ToolResult: Equatable, Sendable {
public var isError: Bool
/// Optional machine-readable payload, surfaced as `structuredContent` (MCP 2025-06-18).
public var structured: JSONValue?
/// When set, `json` is this value verbatim: an upstream MCP server's own
/// `tools/call` result, relayed without re-encoding (keeps image blocks etc.).
public var rawPassthrough: JSONValue? = nil

public init(content: [ToolContent], isError: Bool = false, structured: JSONValue? = nil) {
self.content = content; self.isError = isError; self.structured = structured
Expand All @@ -33,6 +36,7 @@ public struct ToolResult: Equatable, Sendable {
}

public var json: JSONValue {
if let raw = rawPassthrough { return raw }
var o: [String: JSONValue] = ["content": .array(content.map(\.json))]
if isError { o["isError"] = true }
if let s = structured { o["structuredContent"] = s }
Expand Down
22 changes: 19 additions & 3 deletions Sources/InstanceMCPCore/ToolProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ public enum ToolProfile: String, Codable, Sendable, CaseIterable {
case owner
case sandbox

/// `nil` means "no filter". Match is on the tool name.
/// Match is on the tool name.
public func allows(_ toolName: String) -> Bool {
switch self {
case .owner: return true
case .sandbox: return !toolName.hasPrefix("exec")
case .sandbox:
if toolName.hasPrefix("exec") { return false }
if toolName.hasPrefix("browser_") { return Self.sandboxBrowserTools.contains(toolName) }
return true
}
}

/// Playwright MCP tools a lent sandbox may use: navigate, read, interact.
/// Not: arbitrary code in the browser process, the filesystem (upload / PDF),
/// raw network inspection, dialogs, media emulation, closing the browser.
/// Anything Playwright adds later is denied until listed here.
public static let sandboxBrowserTools: Set<String> = [
"browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_find",
"browser_click", "browser_type", "browser_fill_form", "browser_press_key", "browser_hover",
"browser_select_option", "browser_wait_for", "browser_tabs", "browser_take_screenshot",
"browser_console_messages", "browser_resize", "browser_evaluate",
]
}

extension MCPServer {
Expand All @@ -33,7 +47,9 @@ extension MCPServer {
name: serverName,
version: serverVersion,
instructions: instructions ?? self.instructions,
tools: allTools.filter { profile.allows($0.name) }
tools: allTools.filter { profile.allows($0.name) },
upstreams: upstreams,
upstreamFilter: profile
)
}
}
175 changes: 175 additions & 0 deletions Sources/InstanceMCPCore/UpstreamMCP.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import Foundation

/// A loopback MCP server whose tools this daemon re-serves under its own roof —
/// first case: `@playwright/mcp` on `127.0.0.1:8794`, so a lent sandbox session
/// gets `browser_*` tools without any path from the pod to the browser (issue #10).
///
/// Streamable HTTP client, request/response only. The upstream's `Mcp-Session-Id`
/// is held here and re-established when the upstream forgets it (400/404). Replies
/// may arrive as `application/json` or as an SSE frame (`data: {…}`); both parsed.
///
/// `Host` is sent as the bare host without the port: Playwright MCP's
/// `--allowed-hosts` compares the header verbatim, and `127.0.0.1:8794` is not on
/// its list while `127.0.0.1` is.
public actor UpstreamMCP {
public let name: String
public let url: URL
/// Prefix every tool name is exposed under. Playwright's tools already carry
/// `browser_`, so the default is empty; set it for upstreams that do not.
public let prefix: String
private let log: @Sendable (String) -> Void
private let session: URLSession
private var sessionID: String?
private var cachedTools: (at: Date, tools: [JSONValue])?
private let cacheTTL: TimeInterval = 30
public private(set) var lastError: String?

public init(name: String, url: URL, prefix: String = "", timeout: TimeInterval = 90,
log: @escaping @Sendable (String) -> Void = { _ in }) {
self.name = name; self.url = url; self.prefix = prefix; self.log = log
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = timeout
self.session = URLSession(configuration: cfg)
}

// MARK: public surface

/// Tool descriptors with `name` prefixed. Empty when the upstream is down —
/// the merged `tools/list` then simply lacks them (issue #10 acceptance).
public func tools() async -> [JSONValue] {
if let c = cachedTools, Date().timeIntervalSince(c.at) < cacheTTL { return c.tools }
do {
let r = try await rpc("tools/list", params: nil)
let list = (r["result"]?["tools"]?.arrayValue ?? []).map { t -> JSONValue in
guard var o = t.objectValue, let n = o["name"]?.stringValue else { return t }
o["name"] = .string(prefix + n)
return .object(o)
}
cachedTools = (Date(), list)
lastError = nil
return list
} catch {
lastError = "\(error)"
log("upstream \(name): tools/list failed: \(error)")
cachedTools = (Date(), [])
return []
}
}

/// Forward a `tools/call`. `name` is the prefixed name the caller used.
public func call(_ name: String, arguments: JSONValue) async throws -> JSONValue {
let upstreamName = name.hasPrefix(prefix) ? String(name.dropFirst(prefix.count)) : name
let r = try await rpc("tools/call", params: ["name": .string(upstreamName), "arguments": arguments])
if let e = r["error"] { throw JSONRPCError(code: e["code"]?.intValue ?? -32000, message: e["message"]?.stringValue ?? "upstream error") }
return r["result"] ?? .null
}

public func status() async -> JSONValue {
let n = await tools().count
return ["name": .string(name), "url": .string(url.absoluteString), "tools": .number(Double(n)),
"session": .bool(sessionID != nil), "error": lastError.map { .string($0) } ?? .null]
}

/// Does not hit the network; for the `sys_info` line.
public func isKnownHealthy() -> Bool { lastError == nil && (cachedTools?.tools.isEmpty == false) }

// MARK: wire

private func rpc(_ method: String, params: JSONValue?) async throws -> JSONValue {
if sessionID == nil { try await initialize() }
let (status, body) = try await post(["jsonrpc": "2.0", "id": .number(Double(Int.random(in: 1...1_000_000))), "method": .string(method), "params": params ?? [:]])
if status == 404 || status == 400 {
// Upstream lost our session (restart). One re-init, one retry.
sessionID = nil
try await initialize()
let (s2, b2) = try await post(["jsonrpc": "2.0", "id": 1, "method": .string(method), "params": params ?? [:]])
guard (200..<300).contains(s2) else { throw UpstreamError.http(s2, b2) }
return try Self.parseBody(b2)
}
guard (200..<300).contains(status) else { throw UpstreamError.http(status, body) }
return try Self.parseBody(body)
}

private func initialize() async throws {
let (status, body, headers) = try await postRaw([
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": ["protocolVersion": "2025-06-18", "capabilities": [:],
"clientInfo": ["name": "oab-instance-mcp", "version": "upstream"]],
])
guard (200..<300).contains(status) else { throw UpstreamError.http(status, body) }
sessionID = headers.first { $0.key.lowercased() == "mcp-session-id" }?.value
_ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"])
let info = (try? Self.parseBody(body))?["result"]?["serverInfo"]
log("upstream \(name): connected to \(info?["name"]?.stringValue ?? "?") \(info?["version"]?.stringValue ?? "")")
cachedTools = nil
}

private func post(_ msg: JSONValue) async throws -> (Int, Data) {
let (s, b, _) = try await postRaw(msg); return (s, b)
}

private func postRaw(_ msg: JSONValue) 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")
if let h = url.host { req.setValue(h, forHTTPHeaderField: "Host") }
if let sid = sessionID { req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") }
let (data, resp) = try await session.data(for: req)
let http = resp as? HTTPURLResponse
var headers: [String: String] = [:]
for (k, v) in http?.allHeaderFields ?? [:] { if let k = k as? String, let v = v as? String { headers[k] = v } }
return (http?.statusCode ?? 0, data, headers)
}

/// JSON, or the first `data:` line of an SSE body.
static func parseBody(_ data: Data) throws -> JSONValue {
if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: data) { return v }
let text = String(decoding: data, as: UTF8.self)
for line in text.split(separator: "\n") where line.hasPrefix("data:") {
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: Data(payload.utf8)) { return v }
}
throw UpstreamError.badBody(String(text.prefix(200)))
}

public enum UpstreamError: Error, CustomStringConvertible {
case http(Int, Data)
case badBody(String)
public var description: String {
switch self {
case .http(let s, let d): return "upstream HTTP \(s): \(String(decoding: d.prefix(200), as: UTF8.self))"
case .badBody(let s): return "upstream returned neither JSON nor SSE: \(s)"
}
}
}
}

/// A `Tool` that forwards to an upstream. One per upstream tool, built from the
/// upstream's descriptor at list time, so the schema the agent sees is the
/// upstream's own.
struct UpstreamTool: Tool {
let descriptorValue: JSONValue
let upstream: UpstreamMCP

var name: String { descriptorValue["name"]?.stringValue ?? "?" }
var description: String { descriptorValue["description"]?.stringValue ?? "" }
var inputSchema: JSONValue { descriptorValue["inputSchema"] ?? ["type": "object"] }
var descriptor: JSONValue { descriptorValue }

func call(arguments: JSONValue) async throws -> ToolResult {
let r = try await upstream.call(name, arguments: arguments)
// Pass the upstream's result through verbatim: content blocks, isError,
// structuredContent. Re-wrapping would lose image blocks.
return ToolResult(passthrough: r)
}
}

extension ToolResult {
/// A result whose JSON is exactly `raw` (an upstream's `tools/call` result).
init(passthrough raw: JSONValue) {
self.init(content: [], isError: raw["isError"]?.boolValue ?? false, structured: nil)
self.rawPassthrough = raw
}
}
Loading
Loading