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
45 changes: 43 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
platforms: [
.iOS(.v12),
.tvOS(.v12),
.macOS(.v11),
.macOS(.v13),
],
products: [
.library(
Expand All @@ -42,8 +42,18 @@ let package = Package(
type: .dynamic,
targets: ["LookinServerInjected"]
),
.library(
name: "LookinMCPCore",
targets: ["LookinMCPCore"]
),
.executable(
name: "lookinside-mcp",
targets: ["LookinMCPServer"]
),
],
dependencies: [
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0"),
],
dependencies: [],
targets: [
.target(
name: "LookinServerBase",
Expand Down Expand Up @@ -109,5 +119,36 @@ let package = Package(
.linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS])),
]
),
.target(
name: "LookinMCPCore",
dependencies: ["LookinCore"],
path: "Sources/LookinMCPCore",
swiftSettings: [
.define("SHOULD_COMPILE_LOOKIN_SERVER"),
.define("SPM_LOOKIN_SERVER_ENABLED"),
]
),
.executableTarget(
name: "LookinMCPServer",
dependencies: [
"LookinMCPCore",
.product(name: "MCP", package: "swift-sdk"),
],
path: "Sources/LookinMCPServer",
swiftSettings: [
.define("SHOULD_COMPILE_LOOKIN_SERVER"),
.define("SPM_LOOKIN_SERVER_ENABLED"),
]
),
.testTarget(
name: "LookinMCPCoreTests",
dependencies: ["LookinMCPCore"],
path: "Tests/LookinMCPCoreTests",
resources: [.process("Fixtures")],
swiftSettings: [
.define("SHOULD_COMPILE_LOOKIN_SERVER"),
.define("SPM_LOOKIN_SERVER_ENABLED"),
]
),
]
)
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ LookInside continues the work of [Lookin](https://lookin.work/), the original iO

Use [LookInside-Release](https://github.com/LookInsideApp/LookInside-Release) with Swift Package Manager or CocoaPods.

## MCP integration (Debug)

LookInside ships an optional MCP server, `lookinside-mcp`, so AI coding agents (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, …) can inspect the running Debug build directly — hierarchy, screenshots, element search, highlight, layout/accessibility diagnostics, and a one-shot bug report.

Build and try it:

```sh
./Scripts/build-mcp-server.sh
./build/lookinside-mcp health
./build/lookinside-mcp print-config claude-desktop
```

See [docs/mcp.md](docs/mcp.md) for the full feature set, [docs/mcp-client-configs.md](docs/mcp-client-configs.md) for client setup, and [docs/mcp-troubleshooting.md](docs/mcp-troubleshooting.md) if something looks off.

## License

GPL-3.0. See [`LICENSE`](LICENSE).
22 changes: 22 additions & 0 deletions Scripts/build-mcp-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Build the lookinside-mcp executable in release configuration and stage the
# binary at ./build/lookinside-mcp. Designed to be safe to re-run.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

OUT_DIR="$REPO_ROOT/build"
mkdir -p "$OUT_DIR"

echo "› swift build -c release --product lookinside-mcp"
swift build -c release --product lookinside-mcp

BIN_PATH="$(swift build -c release --product lookinside-mcp --show-bin-path)/lookinside-mcp"
cp "$BIN_PATH" "$OUT_DIR/lookinside-mcp"
chmod +x "$OUT_DIR/lookinside-mcp"

echo
echo "Built: $OUT_DIR/lookinside-mcp"
echo "Try: $OUT_DIR/lookinside-mcp health"
echo "Or: $OUT_DIR/lookinside-mcp print-config claude-desktop"
68 changes: 68 additions & 0 deletions Sources/LookinMCPCore/BugReportBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation
import LookinCore
#if canImport(AppKit)
import AppKit
#endif

/// Bundles everything an agent or developer needs to reproduce a UI bug into one
/// JSON payload. Tools producing different views of the same data (hierarchy,
/// diagnostics, screenshot) all flow through this one builder so the format stays
/// consistent — bug reports across teams should look identical.
public enum BugReportBuilder {
public struct Report: Codable {
public let generatedAt: String
public let mcpVersion: String
public let app: AppMeta
public let device: DeviceMeta
public let hierarchy: JSONShape.Node?
public let screenshotBase64PNG: String?
public let layoutFindings: [Finding]
public let accessibilityFindings: [Finding]
}

public struct AppMeta: Codable {
public let name: String?
public let bundleIdentifier: String?
public let serverVersion: Int
}

public struct DeviceMeta: Codable {
public let description: String?
public let os: String?
public let screenWidth: Double
public let screenHeight: Double
public let screenScale: Double
}

public static func build(provider: HierarchyProvider,
includeScreenshot: Bool) throws -> Report {
let info = try provider.hierarchy()
let index = HierarchyIndex(info: info)
let app = try provider.appInfo()
let root = (info.displayItems as? [LookinDisplayItem])?.first
let rootNode = root.map { JSONShape.node($0, index: index, maxDepth: -1, includeOffscreen: false) }
return Report(
generatedAt: ISO8601DateFormatter().string(from: Date()),
mcpVersion: LookinMCP.version,
app: AppMeta(name: app.appName, bundleIdentifier: app.appBundleIdentifier, serverVersion: Int(app.serverVersion)),
device: DeviceMeta(description: app.deviceDescription, os: app.osDescription,
screenWidth: app.screenWidth, screenHeight: app.screenHeight, screenScale: app.screenScale),
hierarchy: rootNode,
screenshotBase64PNG: includeScreenshot ? Self.pngBase64(try provider.screenshot()) : nil,
layoutFindings: LayoutDiagnostics.run(on: index),
accessibilityFindings: AccessibilityDiagnostics.run(on: index)
)
}

public static func pngBase64(_ image: PlatformImage?) -> String? {
guard let image else { return nil }
#if canImport(AppKit)
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { return nil }
return png.base64EncodedString()
#else
return nil
#endif
}
}
59 changes: 59 additions & 0 deletions Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import LookinCore

/// Accessibility-shaped problems detectable from snapshot data. We don't try to
/// simulate VoiceOver — focus on findings every developer should address before
/// shipping (missing labels, undersized targets, duplicate labels).
public enum AccessibilityDiagnostics {
public static func run(on index: HierarchyIndex, scopeOid: UInt? = nil) -> [Finding] {
var out: [Finding] = []
var seenLabels: [String: [(UInt, String)]] = [:]

index.walkAll { item in
if let scope = scopeOid, HierarchyIndex.oid(of: item) != scope { return }
guard let oid = HierarchyIndex.oid(of: item), ElementSearch.isVisible(item) else { return }
let className = JSONShape.primaryClassName(item)
let role = JSONShape.inferRole(className: className)
let path = index.breadcrumb(of: oid)

// Missing label on interactive element.
if role == "button" || role == "switch" || role == "slider" {
let label = JSONShape.extractAttribute(item, identifier: "accessibilityLabel") as? String
let text = JSONShape.extractText(item)
if (label?.isEmpty ?? true), (text?.isEmpty ?? true) {
out.append(Finding(oid: oid, severity: .warning, category: .accessibility,
code: "a11y.missing_label",
message: "\(className) is interactive but has no accessibility label or visible text.",
suggestion: "Set `accessibilityLabel` so VoiceOver users can identify the control.",
path: path))
}
}

// Tiny target, surface in accessibility too for severity-aware tooling.
if (role == "button" || role == "switch") &&
(item.frame.width < 44 || item.frame.height < 44) {
out.append(Finding(oid: oid, severity: .info, category: .accessibility,
code: "a11y.touch_target_small",
message: "\(className) is \(Int(item.frame.width))×\(Int(item.frame.height)) — below the 44pt accessibility minimum.",
suggestion: "Increase hit area.",
path: path))
}

// Bucket labels to detect duplicates.
if let label = JSONShape.extractAttribute(item, identifier: "accessibilityLabel") as? String, !label.isEmpty {
seenLabels[label, default: []].append((oid, path))
}
}

for (label, list) in seenLabels where list.count > 1 {
for (oid, path) in list {
out.append(Finding(oid: oid, severity: .info, category: .accessibility,
code: "a11y.duplicate_label",
message: "Multiple elements share the accessibility label \"\(label)\" (\(list.count) total).",
suggestion: "Disambiguate with `accessibilityHint` or distinct labels — VoiceOver users can't tell them apart.",
path: path))
}
}
return out
}
}
19 changes: 19 additions & 0 deletions Sources/LookinMCPCore/Diagnostics/Finding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// Output shape shared by every diagnostic. Keeping one type means the AI can learn
/// the format once and stay oriented across `diagnose_layout`, `diagnose_accessibility`,
/// and any future linter we bolt on.
public struct Finding: Codable {
public enum Severity: String, Codable { case info, warning, error }
public enum Category: String, Codable { case layout, accessibility, performance, other }

public let oid: UInt?
public let severity: Severity
public let category: Category
/// Stable machine-readable id. New checks pick a new id; renaming an existing
/// check is a breaking change for downstream automation.
public let code: String
public let message: String
public let suggestion: String?
public let path: String?
}
117 changes: 117 additions & 0 deletions Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Foundation
import CoreGraphics
import LookinCore

/// Layout-shaped problems we can detect from a hierarchy snapshot alone — no need
/// to query intrinsic content size live. Heuristics intentionally err on the side
/// of false positives that a human can easily dismiss; missing real bugs is worse.
public enum LayoutDiagnostics {
public static func run(on index: HierarchyIndex, scopeOid: UInt? = nil) -> [Finding] {
var out: [Finding] = []
let interactive = collectInteractive(index: index)

index.walkAll { item in
if let scope = scopeOid, HierarchyIndex.oid(of: item) != scope { return }
check(item, index: index, into: &out)
}
out.append(contentsOf: overlapFindings(interactive, index: index))
return out
}

private static func check(_ item: LookinDisplayItem, index: HierarchyIndex, into out: inout [Finding]) {
guard let oid = HierarchyIndex.oid(of: item) else { return }
let path = index.breadcrumb(of: oid)
let className = JSONShape.primaryClassName(item)
let frame = item.frame

// Zero-size view that should have content
if (frame.width <= 0 || frame.height <= 0), !item.isHidden, item.alpha > 0.01 {
if className.hasSuffix("Label") || className.hasSuffix("Button") || className == "UIImageView" {
out.append(Finding(oid: oid, severity: .warning, category: .layout,
code: "layout.zero_size",
message: "\(className) has zero-area frame \(frame).",
suggestion: "Check constraints — the view may be missing width/height or have a content-hugging conflict.",
path: path))
}
}

// Offscreen relative to parent
if let parentOid = index.ancestorOids(of: oid).first,
let parent = index.find(oid: parentOid) {
let pb = parent.bounds
if !pb.isNull, !pb.isEmpty {
let intersection = pb.intersection(frame)
if intersection.isNull || intersection.isEmpty {
out.append(Finding(oid: oid, severity: .warning, category: .layout,
code: "layout.offscreen_of_parent",
message: "\(className) at \(frame) is fully outside its parent bounds \(pb).",
suggestion: "If intentional, ensure parent has clipsToBounds=false; otherwise fix layout.",
path: path))
}
}
}

// Tiny interactive target
if ElementSearch.isVisible(item), isInteractive(item) {
if frame.width < 44 || frame.height < 44 {
out.append(Finding(oid: oid, severity: .warning, category: .layout,
code: "layout.tap_target_small",
message: "\(className) tap target is \(Int(frame.width))×\(Int(frame.height)) — Apple HIG recommends ≥ 44×44.",
suggestion: "Expand the hit area or pad the view.",
path: path))
}
}

// Hidden but interactive
if isInteractive(item), (item.isHidden || item.alpha < 0.01) {
out.append(Finding(oid: oid, severity: .info, category: .layout,
code: "layout.interactive_but_invisible",
message: "\(className) is interactive but hidden=\(item.isHidden) alpha=\(item.alpha).",
suggestion: "Either disable user interaction or restore visibility — invisible interactive views confuse users and accessibility tools.",
path: path))
}
}

private static func overlapFindings(_ interactive: [LookinDisplayItem],
index: HierarchyIndex) -> [Finding] {
var out: [Finding] = []
// O(n²) but interactive set is small in practice; if it ever gets big, sweep on x.
for i in 0..<interactive.count {
for j in (i + 1)..<interactive.count {
let a = interactive[i], b = interactive[j]
guard ElementSearch.isVisible(a), ElementSearch.isVisible(b) else { continue }
let inter = a.frame.intersection(b.frame)
let minArea = min(a.frame.width * a.frame.height, b.frame.width * b.frame.height)
if !inter.isNull, !inter.isEmpty, minArea > 0,
(inter.width * inter.height) / minArea > 0.5,
let oa = HierarchyIndex.oid(of: a), let ob = HierarchyIndex.oid(of: b) {
out.append(Finding(oid: oa, severity: .warning, category: .layout,
code: "layout.interactive_overlap",
message: "Interactive views overlap (oids \(oa), \(ob)).",
suggestion: "One of them likely swallows taps. Inspect z-order and userInteractionEnabled.",
path: index.breadcrumb(of: oa)))
}
}
}
return out
}

private static func collectInteractive(index: HierarchyIndex) -> [LookinDisplayItem] {
var arr: [LookinDisplayItem] = []
index.walkAll { item in
if isInteractive(item) { arr.append(item) }
}
return arr
}

private static func isInteractive(_ item: LookinDisplayItem) -> Bool {
let cn = JSONShape.primaryClassName(item)
if cn.hasSuffix("Button") || cn == "UIControl" || cn == "NSControl" || cn == "UISwitch" || cn == "UISlider" {
return true
}
if let chain = item.viewObject?.classChainList as? [String] {
return chain.contains(where: { $0 == "UIControl" || $0 == "NSControl" || $0.hasSuffix("Button") })
}
return false
}
}
Loading