Skip to content

Porthole: agent/MCP access to a running iOS app#131

Open
kyleve wants to merge 18 commits into
mainfrom
cursor/porthole
Open

Porthole: agent/MCP access to a running iOS app#131
kyleve wants to merge 18 commits into
mainfrom
cursor/porthole

Conversation

@kyleve

@kyleve kyleve commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Porthole gives local agents (and humans) access to the data and operations inside a running iOS app from a Mac — over a paired Bonjour/TCP bridge — through an MCP server, a command-line tool, and a Mac Catalyst app. Apps expose surfaces by registering connectors (actions + data sources); the suite ships built-in connectors for app info, the view/accessibility hierarchy + screenshots, the file system, notifications, permissions, Periscope logs, SwiftData stores, and LifecycleKit launch state, and the Where app adds a domain-specific WhereConnector.

It ships in release but is inert until the app calls start(); Where gates it behind a #if DEBUG developer-menu toggle.

Architecture

iOS app:  Porthole(configuration:) + register(connectors) + start()
              │  advertises _porthole._tcp, accepts paired, encrypted sessions
              ▼
Mac:  PortholeClientKit  ──►  porthole CLI  /  porthole mcp  /  Porthole.app
  • No Mac daemon in v1 — each surface links PortholeClientKit and connects directly; the MCP is a stdio subprocess. Credentials live in the login keychain, shared across surfaces.
  • Application-layer crypto (not TLS-PSK): a 6-digit-code pairing handshake (X25519 + HKDF) establishes a PSK; sessions derive a fresh key and seal every frame with ChaCha20-Poly1305 under a counter-derived nonce, so replay/reorder/tamper fail closed. Deterministic and fully loopback-testable.
  • Mac app is Catalyst, unsandboxed (a dev tool), so it shares the keychain + local network with the CLI/MCP and can later run on iPad.

Modules

Device side: PortholeCore (wire protocol, framing, crypto — iOS + macOS), PortholeKit (runtime + Bonjour listener + built-in connectors), PortholeKitUI (Broadway-styled pairing view), PortholePeriscope / PortholeSwiftData / PortholeLifecycle (library connectors), Where/WherePorthole (WhereConnector).

Mac side: PortholeClientKit (discovery/pairing/sessions), PortholeMCP (MCP stdio server on the official Swift SDK), PortholeCLICore + the PortholeCLI tool, and PortholeApp (the Catalyst Porthole app).

Connectors

  • Built-ins (auto-registered): app (info + ping), ui (windows / view-tree / accessibility-tree + screenshot & open-url), files (sandbox + App Group roots, capped reads, tested path-traversal/symlink guard), notifications, permissions (reads never prompt).
  • Library: periscope (query history + live tail + scopes), swiftdata (entities + paged rows via a promoted headless SwiftDataInspectorReader), lifecycle (launch state).
  • Where: where — year reports, manual days, evidence metadata, preferences, data issues; plus scan-data-issues, capture-location-now, attribute-coordinate. Read-mostly, no destructive actions.

Testing

All protocol/runtime/connector logic runs in the iOS Stuff-iOS-Tests scheme, hosted in StuffTestHost; the device and client stacks meet end-to-end in one process over a LoopbackTransport (pair → encrypted session → invoke/query/subscribe, plus wrong-code and revoked-pairing rejection). The full scheme is green, including the Broadway double-link regression guards (WhereStylesheetTests + the resolvesTraitAwareTokens tests) — the whole suite reaches the app through WhereUI so no type-keyed metadata is duplicated.

The macOS-only targets (PortholeCLI, PortholeApp for Mac Catalyst) build in a new macos CI job.

Notable decisions

  • MCP Swift SDK pinned to a main revision, not 0.9.0: that release's NetworkTransport.swift fails Swift 6 strict-concurrency under the current toolchain (fixed on main). Documented in Package.swift with a note to move to the next tagged release.
  • New external deps: modelcontextprotocol/swift-sdk (product MCP) and swift-argument-parser.
  • Root Package.swift now also targets .macOS(.v26).

Follow-ups

Tracked in Shared/Porthole/TODOs.md: a menubar daemon hosting a persistent HTTP MCP endpoint, SPAKE2 pairing, blob/backup transfer, write-capable file/preferences/store actions, an App Intents bridge, and an AI connector-builder in the Mac app. There are also two MCP content-API deprecation warnings worth a small follow-up.

Open in Web Open in Cursor 

kyleve added 18 commits July 23, 2026 18:06
Pure-groundwork step (no behavior change): add .macOS(.v26) to the root
Package.swift platforms so the forthcoming Porthole suite can ship
macOS-capable libraries, a command-line tool, and a Mac Catalyst app.
Note the macOS platform + Porthole targets in the root AGENTS.md
deployment section.

Closes plan step: groundwork-platforms
The shared foundation of the Porthole suite (pure Foundation + CryptoKit,
iOS + macOS): PortholeValue (JSON-shaped tree with tagged data/date),
PortholeSchema (JSON-Schema render + runtime validate), typed identifiers
and descriptors, the request/response wire protocol, length-prefixed
framing, the PortholeTransport abstraction + in-memory LoopbackTransport,
the X25519/HKDF pairing handshake, the ChaCha20-Poly1305 PortholeSecureChannel,
and a Keychain-backed PortholeCredentialStore.

Whole stack (framing, pairing crypto, secure channel round-trip incl.
tamper/replay/wrong-key rejection) is tested in-process over the loopback
transport.

Closes plan step: porthole-core
…uter

The device-side runtime over PortholeCore: the @MainActor/@observable
Porthole composition root, the PortholeConnector protocol (+ PortholeAction
/ PortholeDataSource), a main-actor ConnectorRegistry resolving to a Sendable
dispatch snapshot, the actor-based PortholeSessionRouter (decode, validate
against schema, dispatch, bounded drop-oldest subscription streaming), the
built-in app connector (app-info + ping), and PortholeState for the UI.

Networking is not wired yet; the @_spi(Testing) attach(transport:) seam
serves a full session over LoopbackTransport, and PortholeKitTests exercise
hello/list/invoke/query/subscribe end-to-end in-process.

Closes plan step: porthole-kit-runtime
…sessions

Wires Porthole.start()/stop()/revoke() to a real NWListener that advertises
_porthole._tcp with a TXT record and accepts connections. Each connection
runs DevicePairingManager (pure of Network types): the X25519/HKDF pairing
handshake with a single active 6-digit code (accumulating attempts, 3-strike
burn, 120s expiry) and the session re-derivation, then hands the same
TransportFrameReader to a device-role PortholeSecureChannel feeding the
session router.

Adds TransportFrameReader to PortholeCore and a reader-based
PortholeSecureChannel init so the plaintext handshake and encrypted session
share one connection without a second stream iterator. NWConnectionTransport
is a thin framing adapter; the handshake/pairing lifecycle is fully tested
over LoopbackTransport (pairing success, wrong code, 3-strike burn, expiry,
session key match, unknown pairing, revoke).

Closes plan step: porthole-kit-network
The Mac client over PortholeCore: PortholeBrowser (Bonjour discovery),
PortholePairingClient (pair handshake + login-keychain credential
persistence), PortholeClient (re-discover + open a session), and the
PortholeSession actor (id-matched request/response + subscription streams).
ClientHandshake mirrors the device handshake and is pure of Network types.
Promotes NWConnectionTransport into PortholeCore so both sides share it.

PortholeClientKitTests run the real device (PortholeKit) and client stacks
in one process over LoopbackTransport: pair, open an encrypted session, and
invoke/query/subscribe through the secure channel, plus wrong-code and
revoked-pairing rejection.

Closes plan step: porthole-client-kit
Adds swift-argument-parser and PortholeCLICore holding all CLI logic over
PortholeClientKit: devices, pair, unpair, connectors, actions, sources, call
(with destructive confirmation + --out for data), query (with --all paging
and table/json output), tail, and an mcp stub. Pure helpers (CLIValueParsing,
OutputFormatting, AppResolution) are unit-tested without a device. The
PortholeCLI Tuist command-line tool is a thin @main entry that parses and
dispatches async commands explicitly (the availability-safe form). Adds
PortholeClient.unpair.

Closes plan step: porthole-cli
Adds the official MCP Swift SDK and PortholeMCP: MCPToolBuilder plans tools
from a device manifest (porthole_overview, act_/query_/tail_<connector>_<member>
with sanitized names), PortholeMCPDispatcher routes calls to a
PortholeSessionProviding (PNG results -> image content, JSON otherwise, bounded
tail windows, errors -> isError), and PortholeMCPServer wires both to the SDK's
Server + StdioTransport. Implements the real porthole mcp subcommand.

Planner and dispatcher are pure of MCP SDK types and unit-tested against a
scripted fake session. The SDK is pinned to a main revision because 0.9.0's
NetworkTransport fails Swift 6 strict-concurrency under the current toolchain
(fixed on main).

Closes plan step: porthole-mcp
PortholePairingView drives a Porthole's observable state: start/stop
advertising, the large pending pairing-code display, active session count,
and a paired-Macs list with swipe-to-revoke. Appearance lives in
PortholeStylesheet (a Broadway BStylesheet) with portholeBroadwayRoot()
seeding, mirroring PeriscopeTools; the pairing-code face widens tracking at
accessibility sizes.

Tests pin default tokens + environment fallback and host a probe confirming
the trait-aware token resolves across the PortholeKitUI<->BroadwayUI
boundary, plus a render smoke test.

Closes plan step: porthole-kit-ui
A SwiftUI Mac Catalyst app (product Porthole) over PortholeClientKit: sidebar
of paired/discovered apps with pairing (code-entry sheet), a connector
browser, a paged data-source table with a live tail toggle, and a
schema-generated action form. Unsandboxed so it shares the login keychain +
metadata with the CLI and MCP. Logic lives in @observable models
(AppModel/SourceTableModel/ActionFormModel); views render and route.

Adds a macos CI job building PortholeCLI and the Catalyst PortholeApp, and
includes PortholeApp in the Stuff-iOS-Tests build action so the iOS variant
is built too.

Closes plan step: porthole-mac-app
Adds the built-in ui connector to PortholeKit (iOS only, auto-registered):
windows / view-tree / accessibility-tree data sources and screenshot (PNG)
+ open-url actions, all captured on the main actor into Sendable values.
Hosted tests in StuffTestHost drive them over a loopback session: a tagged
view appears in the tree, the screenshot decodes as a PNG, and an invalid
URL is rejected.

Closes plan step: connector-view-hierarchy
Adds the built-in files connector to PortholeKit (auto-registered with the
configured App Group ids): roots + entries data sources and a read-file
action (capped by maxBytes), read-only, with a mandatory path-traversal guard
that resolves symlinks and rejects anything escaping the root. Where passes
its App Group so the shared container is browsable.

Also lets connector handlers throw precise PortholeErrors (e.g. the traversal
guard's invalidParameters) instead of collapsing to handlerFailed. Tests
cover roots/entries/read-file + truncation and the traversal, symlink-escape,
and unknown-root rejections.

Closes plan step: connector-files
Adds two built-in PortholeKit connectors, both behind injectable protocol
seams (NotificationCenterProviding / PermissionsReading) so tests script them
without touching the real frameworks. notifications: settings/pending/
delivered sources + a destructive remove-pending action over
UNUserNotificationCenter. permissions: one row per notifications/location/
camera/microphone/photos authorization status, each framework guarded by
canImport, and every read a status query that never prompts.

Closes plan step: connector-system
New module with a lifecycle connector exposing a launch-state data source
from an injected LifecycleRunner: phase (launching/running/failed/ready),
reason, running step, and any launch failure. Reads the runner on the main
actor and surfaces exactly what it publishes (no fabricated step list). No
actions in v1.

Closes plan step: connector-lifecycle
Makes SwiftDataInspectorReader (and its snapshots InspectorEntity/
InspectorRow/InspectorRowSet/InspectorRelatedRows) public + Sendable so a
non-UI consumer (the forthcoming Porthole SwiftData connector) can browse a
store without SwiftUI. The reflection and views stay internal; the SwiftUI
surface is unchanged. Adds SwiftDataInspectorReaderTests over the public API
and documents the headless surface.

Closes plan step: swiftdata-reader-promotion
New module with SwiftDataConnector over SwiftDataInspector's headless reader:
an entities data source (name/count/columns) and a rows data source (filter
entity, cursor paging over disjoint rowLimit windows). Register one per
ModelContainer with an explicit id. Read-only by construction.

Closes plan step: connector-swiftdata
New module with a periscope connector over PeriscopeStore + Periscope: an
events data source (LogQuery-backed, newest-first, limit+offset cursor, with
minimumLevel/eventName/messageContains/date/afterSequence filters and decoded
JSON payloads), a subscribable live-events tail off liveRecords(), and a
scopes source. Read-only.

Closes plan step: connector-periscope
New Where/WherePorthole module: WhereConnector (id where) over WhereServices
— year-report, manual-days, evidence (metadata), preferences, and data-issues
sources, plus scan-data-issues, capture-location-now, and attribute-coordinate
actions. Preferences cross as a Sendable snapshot. Read-mostly, no destructive
actions.

Wires the whole Porthole suite into the app through WhereUI (the dynamic
framework) — PortholeKit/KitUI/Periscope/SwiftData/Lifecycle/WherePorthole are
added to WhereUI's deps only, so no duplicate copy can split Broadway's
type-keyed metadata (the app target and WhereUITests add nothing). A DEBUG-only
PortholeDeveloperView builds the Porthole, registers the Where/Periscope/
SwiftData connectors (built-ins self-register), and shows PortholePairingView
from a new developer-menu row. Full Stuff-iOS-Tests scheme green, including the
double-link regression guards.

Also hardens the end-to-end test's #require(key(for:)) against a Swift 6.2
macro type-check fragility.

Closes plan step: connector-where
Adds Shared/Porthole/TODOs.md (daemon/HTTP MCP, SPAKE2, blob transfer, write
ops, App Intents bridge, AI connector-builder, iPad) and a top-level
Shared/Porthole/README.md tying the modules together. Notes the macOS CI job
in the root AGENTS.md.

Pure docs — no behavior change.

Closes plan step: docs-roadmap
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant