From bfb972783eb2c26d8b364d60fb856f1aa1844b9b Mon Sep 17 00:00:00 2001 From: tastyheadphones Date: Thu, 14 May 2026 11:13:09 +0900 Subject: [PATCH 1/2] Add Debug-only MCP integration for AI-assisted UI inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `lookinside-mcp`, an optional MCP server that lets AI agents inspect a running Debug build's UI through the same Peertalk plumbing the macOS app uses — hierarchy, search, element details, screenshots, highlight, layout/accessibility diagnostics, and a one-shot bug report. Two new SPM units: LookinMCPCore (headless inspection client, JSON shaping, diagnostics, secure-text redaction) and lookinside-mcp (executable using modelcontextprotocol/swift-sdk over stdio). Reuses LookinCore data models and the existing in-app LookinServer; license gate is bypassed because it is enforced client-side, not by the in-process server. --- Package.swift | 45 ++- README.md | 14 + Scripts/build-mcp-server.sh | 22 ++ Sources/LookinMCPCore/BugReportBuilder.swift | 68 ++++ .../AccessibilityDiagnostics.swift | 59 ++++ .../LookinMCPCore/Diagnostics/Finding.swift | 19 ++ .../Diagnostics/LayoutDiagnostics.swift | 117 +++++++ Sources/LookinMCPCore/ElementSearch.swift | 66 ++++ .../LookinMCPCore/FileHierarchyProvider.swift | 77 +++++ Sources/LookinMCPCore/HierarchyIndex.swift | 71 ++++ Sources/LookinMCPCore/HierarchyProvider.swift | 73 +++++ Sources/LookinMCPCore/JSONShape.swift | 133 ++++++++ Sources/LookinMCPCore/LiveLookinClient.swift | 305 ++++++++++++++++++ Sources/LookinMCPCore/LookinMCPCore.swift | 15 + .../LookinMCPCore/SecureTextRedactor.swift | 26 ++ Sources/LookinMCPServer/CLI.swift | 58 ++++ Sources/LookinMCPServer/HealthCommand.swift | 31 ++ .../LookinMCPServer/PrintConfigCommand.swift | 75 +++++ Sources/LookinMCPServer/ServeCommand.swift | 60 ++++ Sources/LookinMCPServer/ToolRegistry.swift | 69 ++++ Sources/LookinMCPServer/ToolSupport.swift | 59 ++++ Sources/LookinMCPServer/Tools/Tools.swift | 302 +++++++++++++++++ Sources/LookinMCPServer/main.swift | 6 + .../LookinMCPCoreTests/DiagnosticsTests.swift | 25 ++ .../ElementSearchTests.swift | 33 ++ Tests/LookinMCPCoreTests/Fixtures.swift | 72 +++++ Tests/LookinMCPCoreTests/Fixtures/.keep | 0 .../HierarchyIndexTests.swift | 41 +++ Tests/LookinMCPCoreTests/JSONShapeTests.swift | 29 ++ .../ProviderErrorTests.swift | 20 ++ docs/mcp-client-configs.md | 85 +++++ docs/mcp-troubleshooting.md | 63 ++++ docs/mcp.md | 96 ++++++ 33 files changed, 2232 insertions(+), 2 deletions(-) create mode 100755 Scripts/build-mcp-server.sh create mode 100644 Sources/LookinMCPCore/BugReportBuilder.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/Finding.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift create mode 100644 Sources/LookinMCPCore/ElementSearch.swift create mode 100644 Sources/LookinMCPCore/FileHierarchyProvider.swift create mode 100644 Sources/LookinMCPCore/HierarchyIndex.swift create mode 100644 Sources/LookinMCPCore/HierarchyProvider.swift create mode 100644 Sources/LookinMCPCore/JSONShape.swift create mode 100644 Sources/LookinMCPCore/LiveLookinClient.swift create mode 100644 Sources/LookinMCPCore/LookinMCPCore.swift create mode 100644 Sources/LookinMCPCore/SecureTextRedactor.swift create mode 100644 Sources/LookinMCPServer/CLI.swift create mode 100644 Sources/LookinMCPServer/HealthCommand.swift create mode 100644 Sources/LookinMCPServer/PrintConfigCommand.swift create mode 100644 Sources/LookinMCPServer/ServeCommand.swift create mode 100644 Sources/LookinMCPServer/ToolRegistry.swift create mode 100644 Sources/LookinMCPServer/ToolSupport.swift create mode 100644 Sources/LookinMCPServer/Tools/Tools.swift create mode 100644 Sources/LookinMCPServer/main.swift create mode 100644 Tests/LookinMCPCoreTests/DiagnosticsTests.swift create mode 100644 Tests/LookinMCPCoreTests/ElementSearchTests.swift create mode 100644 Tests/LookinMCPCoreTests/Fixtures.swift create mode 100644 Tests/LookinMCPCoreTests/Fixtures/.keep create mode 100644 Tests/LookinMCPCoreTests/HierarchyIndexTests.swift create mode 100644 Tests/LookinMCPCoreTests/JSONShapeTests.swift create mode 100644 Tests/LookinMCPCoreTests/ProviderErrorTests.swift create mode 100644 docs/mcp-client-configs.md create mode 100644 docs/mcp-troubleshooting.md create mode 100644 docs/mcp.md diff --git a/Package.swift b/Package.swift index 507c7fe..4e2321a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( platforms: [ .iOS(.v12), .tvOS(.v12), - .macOS(.v11), + .macOS(.v13), ], products: [ .library( @@ -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", @@ -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"), + ] + ), ] ) diff --git a/README.md b/README.md index 56eb6e4..1bde6d9 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/Scripts/build-mcp-server.sh b/Scripts/build-mcp-server.sh new file mode 100755 index 0000000..85b998f --- /dev/null +++ b/Scripts/build-mcp-server.sh @@ -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" diff --git a/Sources/LookinMCPCore/BugReportBuilder.swift b/Sources/LookinMCPCore/BugReportBuilder.swift new file mode 100644 index 0000000..927f4c5 --- /dev/null +++ b/Sources/LookinMCPCore/BugReportBuilder.swift @@ -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 + } +} diff --git a/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift b/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift new file mode 100644 index 0000000..363320b --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift @@ -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 + } +} diff --git a/Sources/LookinMCPCore/Diagnostics/Finding.swift b/Sources/LookinMCPCore/Diagnostics/Finding.swift new file mode 100644 index 0000000..612695d --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/Finding.swift @@ -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? +} diff --git a/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift b/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift new file mode 100644 index 0000000..46d34a0 --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift @@ -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.. 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 + } +} diff --git a/Sources/LookinMCPCore/ElementSearch.swift b/Sources/LookinMCPCore/ElementSearch.swift new file mode 100644 index 0000000..d689d0f --- /dev/null +++ b/Sources/LookinMCPCore/ElementSearch.swift @@ -0,0 +1,66 @@ +import Foundation +import LookinCore + +/// Predicates for `search_elements`. Each field is optional and ANDed; missing +/// fields are skipped. Mirrors the LookInside.app sidebar filter (class chain, +/// display text, visibility) but expressed declaratively so it's trivial to +/// add new fields later. +public struct ElementQuery { + public var text: String? + public var accessibilityIdentifier: String? + public var className: String? + public var role: String? + public var visibleOnly: Bool + + public init(text: String? = nil, + accessibilityIdentifier: String? = nil, + className: String? = nil, + role: String? = nil, + visibleOnly: Bool = false) { + self.text = text + self.accessibilityIdentifier = accessibilityIdentifier + self.className = className + self.role = role + self.visibleOnly = visibleOnly + } +} + +public enum ElementSearch { + public struct Hit: Codable { + public let oid: UInt + public let className: String + public let role: String? + public let text: String? + public let path: String? + } + + public static func run(_ q: ElementQuery, in index: HierarchyIndex) -> [Hit] { + var hits: [Hit] = [] + index.walkAll { item in + if q.visibleOnly, !isVisible(item) { return } + let className = JSONShape.primaryClassName(item) + if let c = q.className, !className.localizedCaseInsensitiveContains(c) { return } + if let r = q.role, JSONShape.inferRole(className: className) != r { return } + let text = JSONShape.extractText(item) + if let t = q.text { + guard let text = text, text.localizedCaseInsensitiveContains(t) else { return } + } + if let id = q.accessibilityIdentifier { + let aid = JSONShape.extractAttribute(item, identifier: "accessibilityIdentifier") as? String + guard let aid = aid, aid == id else { return } + } + guard let oid = HierarchyIndex.oid(of: item) else { return } + hits.append(Hit(oid: oid, + className: className, + role: JSONShape.inferRole(className: className), + text: text, + path: index.breadcrumb(of: oid))) + } + return hits + } + + public static func isVisible(_ item: LookinDisplayItem) -> Bool { + guard !item.isHidden, item.alpha > 0.01 else { return false } + return item.frame.size.width > 0 && item.frame.size.height > 0 + } +} diff --git a/Sources/LookinMCPCore/FileHierarchyProvider.swift b/Sources/LookinMCPCore/FileHierarchyProvider.swift new file mode 100644 index 0000000..7e013ed --- /dev/null +++ b/Sources/LookinMCPCore/FileHierarchyProvider.swift @@ -0,0 +1,77 @@ +import Foundation +import LookinCore + +/// Reads a `.lookin` snapshot file (the same format the macOS LookInside.app exports +/// from File ▸ Save). Useful when: +/// 1. A developer captures a problem state once and wants to iterate with the AI agent. +/// 2. CI wants to attach a hierarchy artifact to a failed UI test. +/// 3. Tests want a deterministic provider with no networking. +public final class FileHierarchyProvider: HierarchyProvider { + private let info: LookinHierarchyInfo + private let index: HierarchyIndex + + public var isLive: Bool { false } + + public init(fileURL: URL) throws { + let data = try Data(contentsOf: fileURL) + guard let info = try Self.decode(data: data) else { + throw HierarchyProviderError.decodeFailure(reason: "no LookinHierarchyInfo root in \(fileURL.lastPathComponent)") + } + self.info = info + self.index = HierarchyIndex(info: info) + } + + public init(info: LookinHierarchyInfo) { + self.info = info + self.index = HierarchyIndex(info: info) + } + + public func appInfo() throws -> LookinAppInfo { + guard let app = info.appInfo else { + throw HierarchyProviderError.decodeFailure(reason: "snapshot has no appInfo") + } + return app + } + + public func hierarchy() throws -> LookinHierarchyInfo { info } + + public func elementDetails(oid: UInt) throws -> ElementDetails? { + guard let item = index.find(oid: oid) else { return nil } + return ElementDetails(item: item, + attributeGroups: (item.attributesGroupList as? [LookinAttributesGroup]) ?? [], + soloScreenshot: item.soloScreenshot) + } + + public func highlight(oid: UInt, durationMs: Int) throws { + throw HierarchyProviderError.unsupported("highlight requires a live connection — snapshot files cannot drive in-app overlays.") + } + + public func screenshot() throws -> PlatformImage? { + info.appInfo?.screenshot + } + + private static func decode(data: Data) throws -> LookinHierarchyInfo? { + let allowed: [AnyClass] = [ + LookinHierarchyInfo.self, + LookinDisplayItem.self, + LookinAppInfo.self, + LookinAttributesGroup.self, + LookinAttribute.self, + LookinObject.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, NSValue.self, + PlatformImage.self, + ] + let nsClasses = Set(allowed.map { ObjectIdentifier($0) }) + _ = nsClasses // silence unused — informational; we use the array directly below + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + // Snapshot files in the wild use root keys that vary; try the standard ones in order. + for key in [NSKeyedArchiveRootObjectKey, "info", "hierarchyInfo"] { + if let info = unarchiver.decodeObject(of: allowed, forKey: key) as? LookinHierarchyInfo { + return info + } + } + return nil + } +} diff --git a/Sources/LookinMCPCore/HierarchyIndex.swift b/Sources/LookinMCPCore/HierarchyIndex.swift new file mode 100644 index 0000000..55f7191 --- /dev/null +++ b/Sources/LookinMCPCore/HierarchyIndex.swift @@ -0,0 +1,71 @@ +import Foundation +import LookinCore + +/// Flat index over a `LookinHierarchyInfo` tree. Built once per hierarchy fetch; +/// every subsequent oid lookup is O(1). Indices also retain the path from each item +/// up to the root so tools can surface a stable, human-readable breadcrumb. +public final class HierarchyIndex { + public let info: LookinHierarchyInfo + private var byOid: [UInt: LookinDisplayItem] = [:] + private var parents: [UInt: UInt] = [:] + + public init(info: LookinHierarchyInfo) { + self.info = info + for window in (info.displayItems as? [LookinDisplayItem]) ?? [] { + walk(window, parentOid: nil) + } + } + + public func find(oid: UInt) -> LookinDisplayItem? { byOid[oid] } + + public func ancestorOids(of oid: UInt) -> [UInt] { + var out: [UInt] = [] + var cur = parents[oid] + while let next = cur { + out.append(next) + cur = parents[next] + } + return out + } + + public func breadcrumb(of oid: UInt) -> String { + let chain = ([oid] + ancestorOids(of: oid)).reversed() + return chain.compactMap { byOid[$0].flatMap(JSONShape.shortLabel(_:)) }.joined(separator: " ▸ ") + } + + /// In-order walk over every node, root-to-leaf. Provider-agnostic so diagnostics, + /// search, and bug-report builders share one iteration shape. + public func walkAll(_ body: (LookinDisplayItem) -> Void) { + for window in (info.displayItems as? [LookinDisplayItem]) ?? [] { + walkVisit(window, body) + } + } + + public var count: Int { byOid.count } + + private func walk(_ item: LookinDisplayItem, parentOid: UInt?) { + if let oid = Self.oid(of: item) { + byOid[oid] = item + if let p = parentOid { parents[oid] = p } + } + for sub in (item.subitems as? [LookinDisplayItem]) ?? [] { + walk(sub, parentOid: Self.oid(of: item)) + } + } + + private func walkVisit(_ item: LookinDisplayItem, _ body: (LookinDisplayItem) -> Void) { + body(item) + for sub in (item.subitems as? [LookinDisplayItem]) ?? [] { + walkVisit(sub, body) + } + } + + static func oid(of item: LookinDisplayItem) -> UInt? { + // Prefer viewObject, fall back to layer/window. This matches the macOS app's + // display logic where each row is keyed by the underlying view's oid. + if let v = item.viewObject?.oid, v != 0 { return UInt(v) } + if let l = item.layerObject?.oid, l != 0 { return UInt(l) } + if let w = item.windowObject?.oid, w != 0 { return UInt(w) } + return nil + } +} diff --git a/Sources/LookinMCPCore/HierarchyProvider.swift b/Sources/LookinMCPCore/HierarchyProvider.swift new file mode 100644 index 0000000..910d797 --- /dev/null +++ b/Sources/LookinMCPCore/HierarchyProvider.swift @@ -0,0 +1,73 @@ +import Foundation +import LookinCore +#if canImport(AppKit) +import AppKit +public typealias PlatformImage = NSImage +#elseif canImport(UIKit) +import UIKit +public typealias PlatformImage = UIImage +#endif + +/// The seam every MCP tool talks to. Implementations: `LiveLookinClient` (connects +/// to a running Debug build over Peertalk) and `FileHierarchyProvider` (reads a +/// `.lookin` snapshot file). Adding new sources later — say a recorded test fixture +/// or a stub for unit tests — means one more conformance, nothing else changes. +public protocol HierarchyProvider: AnyObject { + /// Top-level metadata about the connected app (or the captured snapshot). + func appInfo() throws -> LookinAppInfo + + /// Full hierarchy tree. May be expensive — callers should cache. + func hierarchy() throws -> LookinHierarchyInfo + + /// Per-element details (screenshot + full attribute groups) for a specific oid. + /// `nil` if the element isn't found in the current hierarchy. + func elementDetails(oid: UInt) throws -> ElementDetails? + + /// Tells the running app to flash a highlight overlay on the element with the + /// given oid. Best-effort — providers without a live channel may no-op. + func highlight(oid: UInt, durationMs: Int) throws + + /// Latest screenshot of the key window. Providers that only have a snapshot + /// return the cached image from `appInfo.screenshot`. + func screenshot() throws -> PlatformImage? + + /// Whether the provider is connected to a live target (vs. a snapshot file). + var isLive: Bool { get } +} + +public struct ElementDetails { + public let item: LookinDisplayItem + public let attributeGroups: [LookinAttributesGroup] + public let soloScreenshot: PlatformImage? + + public init(item: LookinDisplayItem, + attributeGroups: [LookinAttributesGroup], + soloScreenshot: PlatformImage?) { + self.item = item + self.attributeGroups = attributeGroups + self.soloScreenshot = soloScreenshot + } +} + +public enum HierarchyProviderError: Error, CustomStringConvertible { + case noTargetApp + case timeout(requestType: UInt32) + case transport(underlying: Error) + case decodeFailure(reason: String) + case unsupported(String) + + public var description: String { + switch self { + case .noTargetApp: + return "No Debug build of a LookinServer-enabled app is reachable." + case .timeout(let t): + return "Request \(t) timed out talking to the target app." + case .transport(let e): + return "Peertalk transport error: \(e.localizedDescription)" + case .decodeFailure(let r): + return "Response decode failed: \(r)" + case .unsupported(let s): + return "Unsupported operation: \(s)" + } + } +} diff --git a/Sources/LookinMCPCore/JSONShape.swift b/Sources/LookinMCPCore/JSONShape.swift new file mode 100644 index 0000000..cb2e181 --- /dev/null +++ b/Sources/LookinMCPCore/JSONShape.swift @@ -0,0 +1,133 @@ +import Foundation +import CoreGraphics +import LookinCore + +/// Canonical JSON shape every MCP tool returns. Keeping one shape — instead of one +/// per tool — means the agent learns the schema once, and downstream additions are +/// purely additive. +/// +/// Secure-text redaction happens here, at the model boundary, so no tool can leak +/// secure-text-field contents even if a future tool reaches around the data layer. +public enum JSONShape { + public static var redactor: SecureTextRedactor = SecureTextRedactor() + + public struct Node: Codable { + public let oid: UInt + public let className: String + public let role: String? + public let frame: Rect + public let bounds: Rect + public let alpha: Double + public let hidden: Bool + public let text: String? + public let accessibilityIdentifier: String? + public let accessibilityLabel: String? + public let path: String? + public var children: [Node] + } + + public struct Rect: Codable { + public let x: Double; public let y: Double + public let width: Double; public let height: Double + public init(_ r: CGRect) { + self.x = Double(r.origin.x); self.y = Double(r.origin.y) + self.width = Double(r.size.width); self.height = Double(r.size.height) + } + } + + /// Build a node, optionally walking children up to `maxDepth` levels deep + /// (-1 = unlimited). `includeOffscreen` keeps nodes whose frame is fully + /// outside their parent — useful when diagnosing layout escapes. + public static func node(_ item: LookinDisplayItem, + index: HierarchyIndex, + maxDepth: Int, + includeOffscreen: Bool = true, + depth: Int = 0) -> Node { + let oid = HierarchyIndex.oid(of: item) ?? 0 + let className = primaryClassName(item) + let role = inferRole(className: className) + let secure = redactor.isSecure(item: item) + + let kids: [Node] + if maxDepth >= 0 && depth >= maxDepth { + kids = [] + } else { + kids = ((item.subitems as? [LookinDisplayItem]) ?? []) + .filter { includeOffscreen || isOnscreen($0) } + .map { node($0, index: index, maxDepth: maxDepth, includeOffscreen: includeOffscreen, depth: depth + 1) } + } + return Node( + oid: oid, + className: className, + role: role, + frame: Rect(item.frame), + bounds: Rect(item.bounds), + alpha: Double(item.alpha), + hidden: item.isHidden, + text: secure ? nil : extractText(item), + accessibilityIdentifier: extractAttribute(item, identifier: "accessibilityIdentifier") as? String, + accessibilityLabel: extractAttribute(item, identifier: "accessibilityLabel") as? String, + path: index.breadcrumb(of: oid), + children: kids + ) + } + + public static func shortLabel(_ item: LookinDisplayItem) -> String { + primaryClassName(item) + } + + public static func primaryClassName(_ item: LookinDisplayItem) -> String { + if let chain = item.viewObject?.classChainList as? [String], let head = chain.first { return head } + if let chain = item.layerObject?.classChainList as? [String], let head = chain.first { return head } + if let chain = item.windowObject?.classChainList as? [String], let head = chain.first { return head } + return "UnknownView" + } + + public static func inferRole(className: String) -> String? { + switch className { + case "UIButton", "NSButton": return "button" + case "UILabel", "NSTextField": return "label" + case "UIImageView", "NSImageView": return "image" + case "UITextField": return "textInput" + case "UITextView", "NSTextView": return "textArea" + case "UISwitch": return "switch" + case "UISlider": return "slider" + case "UIScrollView", "NSScrollView": return "scroll" + case "UITableView", "NSTableView": return "table" + case "UICollectionView", "NSCollectionView": return "collection" + case "UIStackView", "NSStackView": return "stack" + case "UIWindow", "NSWindow": return "window" + default: + if className.contains("Button") { return "button" } + if className.contains("Label") { return "label" } + return nil + } + } + + public static func isOnscreen(_ item: LookinDisplayItem) -> Bool { + let f = item.frame + return f.size.width > 0 && f.size.height > 0 + } + + public static func extractText(_ item: LookinDisplayItem) -> String? { + // Pull from common attribute identifiers across UILabel/UIButton/UITextField/NSTextField. + for id in ["text", "title", "stringValue", "attributedText"] { + if let s = extractAttribute(item, identifier: id) as? String, !s.isEmpty { return s } + } + return nil + } + + public static func extractAttribute(_ item: LookinDisplayItem, identifier: String) -> Any? { + guard let groups = item.attributesGroupList as? [LookinAttributesGroup] else { return nil } + for group in groups { + guard let sections = group.attrSections as? [LookinAttributesSection] else { continue } + for section in sections { + guard let attrs = section.attributes as? [LookinAttribute] else { continue } + for a in attrs where (a.identifier as String).contains(identifier) { + return a.value + } + } + } + return nil + } +} diff --git a/Sources/LookinMCPCore/LiveLookinClient.swift b/Sources/LookinMCPCore/LiveLookinClient.swift new file mode 100644 index 0000000..8c5abf9 --- /dev/null +++ b/Sources/LookinMCPCore/LiveLookinClient.swift @@ -0,0 +1,305 @@ +import Foundation +import LookinCore +import Darwin + +/// Live connection to a `LookinServer` running inside a Debug build. Speaks the same +/// framed NSSecureCoding protocol the macOS LookInside.app uses (see +/// `LookInside/Connection/LKConnectionManager.m`), but stripped to a synchronous, +/// headless API suitable for an MCP tool dispatch. +/// +/// Why we don't reuse `LKConnectionManager`: +/// - It depends on ReactiveObjC (`RACSignal`), pulling a heavy dep into the SPM build. +/// - It performs a client-side license handshake before non-Ping requests; that gate +/// is enforced by the Mac app, not by `LookinServer` itself (`Sources/LookinServer/ +/// Server/Connection/LKS_RequestHandler.m` has no license check). A separate Debug +/// tooling client is free to skip it. +/// +/// Reachable ports (defined in `LookinDefines.h`): +/// - Simulator: 47164–47169 +/// - USB device: 47175–47179 +/// - macOS target: 47170–47174 +public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChannelDelegate { + + public struct DiscoveredApp { + public let port: Int + public let platform: String // "simulator" | "macos" | "device" + public let appInfo: LookinAppInfo + } + + public var isLive: Bool { true } + + private let queue = DispatchQueue(label: "lookin.mcp.client", qos: .userInitiated) + private let connectTimeout: TimeInterval + private let requestTimeout: TimeInterval + + private var channel: Lookin_PTChannel? + private var pendingRequests: [UInt32: PendingRequest] = [:] + private var hierarchyCache: LookinHierarchyInfo? + private var indexCache: HierarchyIndex? + + public init(connectTimeout: TimeInterval = 1.5, requestTimeout: TimeInterval = 10) { + self.connectTimeout = connectTimeout + self.requestTimeout = requestTimeout + super.init() + } + + // MARK: Discovery & connect + + public func discover() -> [DiscoveredApp] { + let ranges = [ + ("simulator", LookinSimulatorIPv4PortNumberStart...LookinSimulatorIPv4PortNumberEnd), + ("macos", LookinMacIPv4PortNumberStart...LookinMacIPv4PortNumberEnd), + ("device", LookinUSBDeviceIPv4PortNumberStart...LookinUSBDeviceIPv4PortNumberEnd), + ] + var found: [DiscoveredApp] = [] + for (platform, range) in ranges { + for port in range { + guard let client = try? Self.makeAndConnect(port: Int(port), timeout: connectTimeout) else { continue } + defer { client.disconnect() } + if let app = try? client.fetchAppInfo() { + found.append(DiscoveredApp(port: Int(port), platform: platform, appInfo: app)) + } + } + } + return found + } + + /// Connect to the first reachable app, preferring simulator → macOS → device. + @discardableResult + public func connectToFirstAvailable() throws -> DiscoveredApp { + let apps = discover() + guard let pick = apps.first else { throw HierarchyProviderError.noTargetApp } + try connect(port: pick.port) + return pick + } + + public func connect(port: Int) throws { + disconnect() + let ch = try Self.openChannel(port: port, timeout: connectTimeout, delegate: self) + channel = ch + } + + public func disconnect() { + channel?.close() + channel = nil + pendingRequests.removeAll() + hierarchyCache = nil + indexCache = nil + } + + // MARK: HierarchyProvider + + public func appInfo() throws -> LookinAppInfo { try fetchAppInfo() } + + public func hierarchy() throws -> LookinHierarchyInfo { + if let cached = hierarchyCache { return cached } + let resp = try sendRequest(type: UInt32(LookinRequestTypeHierarchy), payload: nil) + guard let info = resp.data as? LookinHierarchyInfo else { + throw HierarchyProviderError.decodeFailure(reason: "expected LookinHierarchyInfo, got \(String(describing: type(of: resp.data)))") + } + hierarchyCache = info + indexCache = HierarchyIndex(info: info) + return info + } + + public func elementDetails(oid: UInt) throws -> ElementDetails? { + let info = try hierarchy() + let index = indexCache ?? HierarchyIndex(info: info) + guard let item = index.find(oid: oid) else { return nil } + // The hierarchy response already carries attribute groups and screenshots for each item. + return ElementDetails(item: item, + attributeGroups: (item.attributesGroupList as? [LookinAttributesGroup]) ?? [], + soloScreenshot: item.soloScreenshot) + } + + public func highlight(oid: UInt, durationMs: Int) throws { + // No first-class highlight request type exists yet on the server. Real + // highlight runs through the macOS app's preview overlay. Until LookinServer + // gains a server-side highlight request (tracked as a follow-up), this is a + // no-op rather than a lie. + throw HierarchyProviderError.unsupported("highlight requires a server-side request type — coming in a follow-up PR.") + } + + public func screenshot() throws -> PlatformImage? { + try fetchAppInfo().screenshot + } + + // MARK: Internals + + fileprivate func fetchAppInfo() throws -> LookinAppInfo { + let resp = try sendRequest(type: UInt32(LookinRequestTypeApp), payload: nil) + guard let info = resp.data as? LookinAppInfo else { + throw HierarchyProviderError.decodeFailure(reason: "expected LookinAppInfo, got \(String(describing: type(of: resp.data)))") + } + return info + } + + private static func makeAndConnect(port: Int, timeout: TimeInterval) throws -> ProbeClient { + let probe = ProbeClient() + let channel = try Self.openChannel(port: port, timeout: timeout, delegate: probe) + probe.channel = channel + return probe + } + + fileprivate static func openChannel(port: Int, + timeout: TimeInterval, + delegate: Lookin_PTChannelDelegate) throws -> Lookin_PTChannel { + guard let channel = Lookin_PTChannel() else { + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to allocate Peertalk channel."])) + } + channel.delegate = delegate + channel.targetPort = port + let sem = DispatchSemaphore(value: 0) + var connectError: Error? + // Loopback in network byte order — Peertalk wants host byte order, so use INADDR_LOOPBACK directly. + channel.connect(toPort: in_port_t(port), iPv4Address: in_addr_t(INADDR_LOOPBACK)) { error, _ in + connectError = error + sem.signal() + } + if sem.wait(timeout: .now() + timeout) == .timedOut { + channel.close() + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Connect to port \(port) timed out."])) + } + if let e = connectError { throw HierarchyProviderError.transport(underlying: e) } + return channel + } + + fileprivate func sendRequest(type: UInt32, payload: Any?) throws -> LookinConnectionResponseAttachment { + guard let channel else { throw HierarchyProviderError.noTargetApp } + let attachment = LookinConnectionAttachment() + attachment.data = payload + let data: Data + do { + data = try NSKeyedArchiver.archivedData(withRootObject: attachment, requiringSecureCoding: true) + } catch { + throw HierarchyProviderError.transport(underlying: error) + } + let tag = UInt32(truncatingIfNeeded: UInt64(Date().timeIntervalSince1970 * 1000)) + let dispatchPayload = data.withUnsafeBytes { raw -> DispatchData in + DispatchData(bytes: raw) + } + let sem = DispatchSemaphore(value: 0) + let pending = PendingRequest() + queue.sync { pendingRequests[tag] = pending } + channel.sendFrame(ofType: type, tag: tag, withPayload: dispatchPayload as __DispatchData) { err in + if let err = err { + self.queue.sync { + pending.error = err + self.pendingRequests.removeValue(forKey: tag) + } + sem.signal() + } + } + if sem.wait(timeout: .now() + requestTimeout) == .timedOut, pending.response == nil { + queue.sync { pendingRequests.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: type) + } + if let response = pending.response { return response } + if let err = pending.error { throw HierarchyProviderError.transport(underlying: err) } + // Wait for the response delivered via delegate callback; if we got here without one, it's a transport issue. + // (sendFrame's callback fires before the response — we need to wait for the read path.) + let secondSem = pending.semaphore + if secondSem.wait(timeout: .now() + requestTimeout) == .timedOut { + queue.sync { pendingRequests.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: type) + } + if let response = pending.response { return response } + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -2, userInfo: [NSLocalizedDescriptionKey: "No response and no error for request \(type)."])) + } + + // MARK: Lookin_PTChannelDelegate + + public func ioFrameChannel(_ channel: Lookin_PTChannel, + didReceiveFrameOfType type: UInt32, + tag: UInt32, + payload: Lookin_PTData?) { + guard let payload else { return } + let data = Data(bytes: payload.data, count: payload.length) + let allowed: [AnyClass] = [ + LookinConnectionResponseAttachment.self, + LookinHierarchyInfo.self, + LookinDisplayItem.self, LookinAppInfo.self, + LookinAttributesGroup.self, LookinAttribute.self, LookinObject.self, + PlatformImage.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, NSValue.self, + ] + guard let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment else { + return + } + queue.async { + if let pending = self.pendingRequests.removeValue(forKey: tag) { + pending.response = response + pending.semaphore.signal() + } + } + } + + public func ioFrameChannel(_ channel: Lookin_PTChannel, didEndWithError error: Error?) { + queue.async { + self.pendingRequests.values.forEach { pending in + pending.error = error ?? NSError(domain: "Lookin", code: -3, userInfo: [NSLocalizedDescriptionKey: "Channel closed."]) + pending.semaphore.signal() + } + self.pendingRequests.removeAll() + self.channel = nil + } + } + + private final class PendingRequest { + var response: LookinConnectionResponseAttachment? + var error: Error? + let semaphore = DispatchSemaphore(value: 0) + } +} + +/// Minimal probe used during port discovery. Mirrors LiveLookinClient's frame +/// handling but holds nothing beyond the channel lifetime. +fileprivate final class ProbeClient: NSObject, Lookin_PTChannelDelegate { + var channel: Lookin_PTChannel? + private let queue = DispatchQueue(label: "lookin.mcp.probe") + private var pending: [UInt32: (LookinConnectionResponseAttachment?) -> Void] = [:] + + func disconnect() { channel?.close(); channel = nil } + + func fetchAppInfo() throws -> LookinAppInfo { + guard let channel else { throw HierarchyProviderError.noTargetApp } + let attachment = LookinConnectionAttachment() + let data = try NSKeyedArchiver.archivedData(withRootObject: attachment, requiringSecureCoding: true) + let tag = UInt32.random(in: 1.. DispatchData in + DispatchData(bytes: raw) + } + let sem = DispatchSemaphore(value: 0) + var resp: LookinConnectionResponseAttachment? + queue.sync { pending[tag] = { resp = $0; sem.signal() } } + channel.sendFrame(ofType: UInt32(LookinRequestTypeApp), tag: tag, withPayload: payload as __DispatchData, callback: nil) + if sem.wait(timeout: .now() + 1.5) == .timedOut { + queue.sync { pending.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: UInt32(LookinRequestTypeApp)) + } + guard let info = resp?.data as? LookinAppInfo else { throw HierarchyProviderError.noTargetApp } + return info + } + + func ioFrameChannel(_ channel: Lookin_PTChannel, didReceiveFrameOfType type: UInt32, tag: UInt32, payload: Lookin_PTData?) { + guard let payload else { return } + let data = Data(bytes: payload.data, count: payload.length) + let allowed: [AnyClass] = [ + LookinConnectionResponseAttachment.self, LookinAppInfo.self, PlatformImage.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, + ] + let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment + queue.async { + if let cb = self.pending.removeValue(forKey: tag) { cb(response) } + } + } + + func ioFrameChannel(_ channel: Lookin_PTChannel, didEndWithError error: Error?) { + queue.async { + self.pending.values.forEach { $0(nil) } + self.pending.removeAll() + } + } +} diff --git a/Sources/LookinMCPCore/LookinMCPCore.swift b/Sources/LookinMCPCore/LookinMCPCore.swift new file mode 100644 index 0000000..29a2a50 --- /dev/null +++ b/Sources/LookinMCPCore/LookinMCPCore.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Public surface of LookinMCPCore — a headless, Swift wrapper over LookInside's +/// existing inspection plumbing for use by external clients (the MCP server, future +/// CLI tools, tests). Live target-app inspection talks to `LookinServer` instances +/// running in Debug builds; offline analysis works against `.lookin` snapshot files. +/// +/// Threading: everything in this module is `Sendable`-friendly. The `LiveLookinClient` +/// dispatches network I/O on a private serial queue; results are delivered on the queue +/// the call originated from. +public enum LookinMCP { + /// Marketing version printed by `lookinside-mcp --version` and surfaced in + /// `health_check`. Bump alongside any tool-schema-affecting change. + public static let version = "0.1.0" +} diff --git a/Sources/LookinMCPCore/SecureTextRedactor.swift b/Sources/LookinMCPCore/SecureTextRedactor.swift new file mode 100644 index 0000000..898fcba --- /dev/null +++ b/Sources/LookinMCPCore/SecureTextRedactor.swift @@ -0,0 +1,26 @@ +import Foundation +import LookinCore + +/// Centralized redaction for secure text fields. Applied by `JSONShape.node` so any +/// tool that surfaces an element's text inherits the protection automatically. +/// +/// The check is conservative: we strip text whenever the class is `UITextField` / +/// `NSSecureTextField` AND the `isSecureTextEntry` (UIKit) or class name itself +/// (`NSSecureTextField`) indicates secure entry. Better to over-redact than to leak. +public struct SecureTextRedactor { + public init() {} + + public func isSecure(item: LookinDisplayItem) -> Bool { + let className = JSONShape.primaryClassName(item) + if className == "NSSecureTextField" { return true } + if className == "UITextField" { + if let secure = JSONShape.extractAttribute(item, identifier: "isSecureTextEntry") as? NSNumber, secure.boolValue { + return true + } + } + // Cover subclasses by checking the full chain. + let chain = (item.viewObject?.classChainList as? [String]) ?? [] + if chain.contains("NSSecureTextField") { return true } + return false + } +} diff --git a/Sources/LookinMCPServer/CLI.swift b/Sources/LookinMCPServer/CLI.swift new file mode 100644 index 0000000..3d2c5eb --- /dev/null +++ b/Sources/LookinMCPServer/CLI.swift @@ -0,0 +1,58 @@ +import Foundation +import LookinMCPCore + +/// Tiny subcommand dispatcher. We deliberately avoid `swift-argument-parser` — +/// every dependency the executable carries pushes the cold-start cost an MCP +/// client pays on every prompt. Keep it lean. +enum CLI { + static func dispatch(_ args: [String]) async -> Int32 { + let cmd = args.first ?? "serve" + switch cmd { + case "--version", "-V": + print("lookinside-mcp \(LookinMCP.version)") + return 0 + case "--help", "-h", "help": + printUsage() + return 0 + case "serve": + return await ServeCommand.run(snapshotPath: argValue(args, "--snapshot")) + case "health": + return HealthCommand.run() + case "print-config": + let client = args.dropFirst().first ?? "" + return PrintConfigCommand.run(client: client) + default: + FileHandle.standardError.write(Data("Unknown command: \(cmd)\n".utf8)) + printUsage() + return 2 + } + } + + static func printUsage() { + let usage = """ + lookinside-mcp \(LookinMCP.version) + + USAGE + lookinside-mcp serve [--snapshot ] + Run the MCP server over stdio. With --snapshot, serves from a `.lookin` + file instead of probing for a live target app (great for offline analysis). + lookinside-mcp health + Print connection status and exit nonzero if no Debug build is reachable. + lookinside-mcp print-config + Print a ready-to-paste config snippet for one of: + claude-desktop | claude-code | cursor | windsurf | vscode + + FLAGS + --version, -V Print version and exit. + --help, -h Show this message. + + Logs are written to stderr to keep stdout reserved for the MCP transport. + """ + print(usage) + } + + private static func argValue(_ args: [String], _ flag: String) -> String? { + guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } + return args[i + 1] + } +} diff --git a/Sources/LookinMCPServer/HealthCommand.swift b/Sources/LookinMCPServer/HealthCommand.swift new file mode 100644 index 0000000..b17726e --- /dev/null +++ b/Sources/LookinMCPServer/HealthCommand.swift @@ -0,0 +1,31 @@ +import Foundation +import LookinMCPCore + +/// `lookinside-mcp health` — entry point developers hit when something looks off. +/// Output is plain text on stdout (humans read this directly) and the JSON-shaped +/// summary on stderr (for piping). Nonzero exit when nothing is reachable so it +/// composes with shell scripts and CI. +enum HealthCommand { + static func run() -> Int32 { + let client = LiveLookinClient(connectTimeout: 0.8) + let apps = client.discover() + print("lookinside-mcp \(LookinMCP.version)") + if apps.isEmpty { + print("status: no_target") + print("No Debug build with LookinServer is currently reachable.") + print("Try:") + print(" • launch your app in a Simulator with LookinServer embedded (SPM or CocoaPods),") + print(" • for a USB device, ensure usbmuxd is running and the device is unlocked,") + print(" • or pass --snapshot to serve from a captured snapshot.") + return 1 + } + print("status: ok") + print("found \(apps.count) reachable app\(apps.count == 1 ? "" : "s"):") + for app in apps { + let name = app.appInfo.appName ?? "" + let bundle = app.appInfo.appBundleIdentifier ?? "" + print(" • \(name) (\(bundle)) — \(app.platform) port \(app.port)") + } + return 0 + } +} diff --git a/Sources/LookinMCPServer/PrintConfigCommand.swift b/Sources/LookinMCPServer/PrintConfigCommand.swift new file mode 100644 index 0000000..836bce1 --- /dev/null +++ b/Sources/LookinMCPServer/PrintConfigCommand.swift @@ -0,0 +1,75 @@ +import Foundation +import LookinMCPCore + +/// Emits a JSON snippet ready to paste into one of the common MCP-aware clients. +/// We resolve the absolute path of the running binary so users don't have to think +/// about $PATH; the snippet works copy-paste from any directory. +enum PrintConfigCommand { + static func run(client: String) -> Int32 { + let binary = currentExecutablePath() + let snippet: String + switch client { + case "claude-desktop": + snippet = """ + // Add into ~/Library/Application Support/Claude/claude_desktop_config.json + { + "mcpServers": { + "lookinside": { + "command": "\(binary)", + "args": ["serve"] + } + } + } + """ + case "claude-code": + snippet = """ + # Run once: + claude mcp add lookinside \(binary) serve + """ + case "cursor": + snippet = """ + // ~/.cursor/mcp.json + { + "mcpServers": { + "lookinside": { "command": "\(binary)", "args": ["serve"] } + } + } + """ + case "windsurf": + snippet = """ + // ~/.codeium/windsurf/mcp_config.json + { + "mcpServers": { + "lookinside": { "command": "\(binary)", "args": ["serve"] } + } + } + """ + case "vscode": + snippet = """ + // VS Code settings.json under "mcp.servers" (Copilot Chat / Claude / continue.dev syntax) + "lookinside": { + "command": "\(binary)", + "args": ["serve"] + } + """ + default: + FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, cursor, windsurf, vscode.\n".utf8)) + return 2 + } + print(snippet) + return 0 + } + + private static func currentExecutablePath() -> String { + var buf = [CChar](repeating: 0, count: 1024) + var size = UInt32(buf.count) + if _NSGetExecutablePath(&buf, &size) == 0 { + let resolved = URL(fileURLWithPath: String(cString: buf)).standardizedFileURL.path + return resolved + } + return CommandLine.arguments[0] + } +} + +@_silgen_name("_NSGetExecutablePath") +private func _NSGetExecutablePath(_ buf: UnsafeMutablePointer, _ bufsize: UnsafeMutablePointer) -> Int32 diff --git a/Sources/LookinMCPServer/ServeCommand.swift b/Sources/LookinMCPServer/ServeCommand.swift new file mode 100644 index 0000000..1d78815 --- /dev/null +++ b/Sources/LookinMCPServer/ServeCommand.swift @@ -0,0 +1,60 @@ +import Foundation +import LookinMCPCore +import MCP + +/// `serve` — boots the stdio MCP server. Tools are registered once via `ToolRegistry`; +/// adding a new tool means dropping a new conformance, not touching this file. +enum ServeCommand { + static func run(snapshotPath: String?) async -> Int32 { + let providerFactory: () throws -> HierarchyProvider = { + if let path = snapshotPath { + return try FileHierarchyProvider(fileURL: URL(fileURLWithPath: path)) + } + let client = LiveLookinClient() + _ = try client.connectToFirstAvailable() + return client + } + let registry = ToolRegistry(providerFactory: providerFactory) + + let server = Server( + name: "lookinside", + version: LookinMCP.version, + instructions: """ + LookInside's MCP integration. Use tools to inspect a running iOS or macOS app's + UI hierarchy, capture screenshots, find elements, and diagnose layout or + accessibility problems. The target app must be a Debug build with LookinServer + embedded; `health_check` reports connection status. Tools never expose + secure-text-field contents. + """, + capabilities: .init(tools: .init(listChanged: false)) + ) + + await server.withMethodHandler(ListTools.self) { _ in + return .init(tools: registry.allTools) + } + + await server.withMethodHandler(CallTool.self) { params in + do { + let result = try await registry.call(name: params.name, arguments: params.arguments ?? [:]) + return .init(content: [.text(text: result, annotations: nil, _meta: nil)], isError: false) + } catch { + let payload = #"{"error":"\#(escape("\(error)"))"}"# + return .init(content: [.text(text: payload, annotations: nil, _meta: nil)], isError: true) + } + } + + FileHandle.standardError.write(Data("lookinside-mcp \(LookinMCP.version) listening on stdio\n".utf8)) + do { + try await server.start(transport: StdioTransport()) + await server.waitUntilCompleted() + return 0 + } catch { + FileHandle.standardError.write(Data("Fatal: \(error)\n".utf8)) + return 1 + } + } + + private static func escape(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/LookinMCPServer/ToolRegistry.swift b/Sources/LookinMCPServer/ToolRegistry.swift new file mode 100644 index 0000000..85333a3 --- /dev/null +++ b/Sources/LookinMCPServer/ToolRegistry.swift @@ -0,0 +1,69 @@ +import Foundation +import LookinMCPCore +import MCP + +/// Registry pattern so adding a new tool is one new file + one line in `tools` below. +/// The provider is created lazily on first call — discovery TCP probes are expensive +/// and we don't want to do them just to answer `tools/list`. +final class ToolRegistry { + private let providerFactory: () throws -> HierarchyProvider + private var provider: HierarchyProvider? + private let lock = NSLock() + let definitions: [LookinTool] + + init(providerFactory: @escaping () throws -> HierarchyProvider) { + self.providerFactory = providerFactory + self.definitions = [ + HealthCheckTool(), ListAppsTool(), + CurrentScreenTool(), GetHierarchyTool(), + SearchElementsTool(), GetElementTool(), + CaptureScreenshotTool(), HighlightElementTool(), + DiagnoseLayoutTool(), DiagnoseAccessibilityTool(), + ExportBugReportTool(), + ] + } + + var allTools: [Tool] { definitions.map(\.asTool) } + + func call(name: String, arguments: [String: Value]) async throws -> String { + guard let def = definitions.first(where: { $0.name == name }) else { + throw RegistryError.unknown(name) + } + return try def.invoke(arguments: arguments, providerFactory: providerFactory) { [weak self] in + try self?.sharedProvider() + } + } + + private func sharedProvider() throws -> HierarchyProvider { + lock.lock(); defer { lock.unlock() } + if let p = provider { return p } + let p = try providerFactory() + provider = p + return p + } + + enum RegistryError: Error, CustomStringConvertible { + case unknown(String) + var description: String { + switch self { case .unknown(let n): return "Unknown tool: \(n)" } + } + } +} + +/// One file per tool, but they all conform to this. Returning a `String` (raw JSON) +/// keeps the layer below this independent of the MCP SDK's `Value` so we can swap +/// SDK versions without rewriting tool bodies. +protocol LookinTool { + var name: String { get } + var description: String { get } + var inputSchema: Value { get } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String +} + +extension LookinTool { + var asTool: Tool { + Tool(name: name, description: description, inputSchema: inputSchema) + } +} diff --git a/Sources/LookinMCPServer/ToolSupport.swift b/Sources/LookinMCPServer/ToolSupport.swift new file mode 100644 index 0000000..4a51eb5 --- /dev/null +++ b/Sources/LookinMCPServer/ToolSupport.swift @@ -0,0 +1,59 @@ +import Foundation +import MCP + +/// Helpers shared by every tool. Reach for `JSON` whenever you need to build a +/// response object — Codable + JSONEncoder gives us a stable, ordered shape that +/// the agent can rely on. Building literals here keeps tool implementations terse. +enum JSON { + /// Encodes any Codable value as a compact JSON string with sorted keys. + static func encode(_ value: T) throws -> String { + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + enc.dateEncodingStrategy = .iso8601 + let data = try enc.encode(value) + return String(data: data, encoding: .utf8) ?? "{}" + } +} + +enum Schema { + /// Inline JSON Schema builders. Keeping these short makes tool definitions + /// readable at a glance. + static let object = "object" + static let string = "string" + static let integer = "integer" + static let boolean = "boolean" + + static func obj(_ properties: [String: Value], required: [String] = []) -> Value { + var dict: [String: Value] = [ + "type": .string(object), + "properties": .object(properties), + ] + if !required.isEmpty { + dict["required"] = .array(required.map { .string($0) }) + } + return .object(dict) + } + + static func prop(_ type: String, description: String? = nil, enumValues: [String]? = nil) -> Value { + var dict: [String: Value] = ["type": .string(type)] + if let d = description { dict["description"] = .string(d) } + if let e = enumValues { dict["enum"] = .array(e.map { .string($0) }) } + return .object(dict) + } + + static let empty: Value = .object(["type": .string(object), "properties": .object([:])]) +} + +extension Value { + func asString() -> String? { if case .string(let s) = self { return s }; return nil } + func asInt() -> Int? { + switch self { + case .int(let i): return i + case .double(let d): return Int(d) + case .string(let s): return Int(s) + default: return nil + } + } + func asBool() -> Bool? { if case .bool(let b) = self { return b }; return nil } + func asUInt() -> UInt? { asInt().flatMap { $0 >= 0 ? UInt($0) : nil } } +} diff --git a/Sources/LookinMCPServer/Tools/Tools.swift b/Sources/LookinMCPServer/Tools/Tools.swift new file mode 100644 index 0000000..413face --- /dev/null +++ b/Sources/LookinMCPServer/Tools/Tools.swift @@ -0,0 +1,302 @@ +import Foundation +import LookinMCPCore +import LookinCore +import MCP + +// MARK: - health_check + +struct HealthCheckTool: LookinTool { + let name = "health_check" + let description = "Report whether a Debug build of a LookinServer-enabled app is reachable. Returns version, port info, and connected-app metadata when present." + var inputSchema: Value { Schema.empty } + + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + struct Result: Codable { let ok: Bool; let version: String; let connectedApp: AppSummary?; let reason: String? } + let probe = LiveLookinClient(connectTimeout: 0.8) + let apps = probe.discover() + if let first = apps.first { + return try JSON.encode(Result(ok: true, version: LookinMCP.version, connectedApp: .from(first), reason: nil)) + } + return try JSON.encode(Result(ok: false, version: LookinMCP.version, connectedApp: nil, reason: "no_target")) + } +} + +// MARK: - list_apps + +struct ListAppsTool: LookinTool { + let name = "list_apps" + let description = "List every Debug app currently reachable on Peertalk ports — useful when multiple simulators or devices are running." + var inputSchema: Value { Schema.empty } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let client = LiveLookinClient(connectTimeout: 0.8) + let apps = client.discover().map { AppSummary.from($0) } + return try JSON.encode(["apps": apps]) + } +} + +struct AppSummary: Codable { + let name: String? + let bundleIdentifier: String? + let platform: String + let port: Int + let serverVersion: Int + let device: String? + let os: String? + static func from(_ d: LiveLookinClient.DiscoveredApp) -> AppSummary { + AppSummary(name: d.appInfo.appName, bundleIdentifier: d.appInfo.appBundleIdentifier, + platform: d.platform, port: d.port, + serverVersion: Int(d.appInfo.serverVersion), + device: d.appInfo.deviceDescription, os: d.appInfo.osDescription) + } +} + +// MARK: - current_screen + +struct CurrentScreenTool: LookinTool { + let name = "current_screen" + let description = "One-shot summary of the active screen: key window class, top view controller, screenshot reference, and a shallow tree (depth 2)." + var inputSchema: Value { Schema.empty } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let app = try? provider.appInfo() + let keyWindow = (info.displayItems as? [LookinDisplayItem])?.first + struct Summary: Codable { + let app: String? + let device: String? + let keyWindow: JSONShape.Node? + let hierarchyDepth: Int + } + let summary = Summary( + app: app?.appName, + device: app?.deviceDescription, + keyWindow: keyWindow.map { JSONShape.node($0, index: index, maxDepth: 2, includeOffscreen: false) }, + hierarchyDepth: index.count + ) + return try JSON.encode(summary) + } +} + +// MARK: - get_hierarchy + +struct GetHierarchyTool: LookinTool { + let name = "get_hierarchy" + let description = "Return the view hierarchy of the key window as a tree of canonical nodes. Use maxDepth to cap traversal for large screens." + var inputSchema: Value { + Schema.obj([ + "maxDepth": Schema.prop(Schema.integer, description: "Cap traversal depth. -1 = unlimited. Default 8."), + "includeOffscreen": Schema.prop(Schema.boolean, description: "Include views whose frame is fully outside their parent. Default false."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let depth = arguments["maxDepth"]?.asInt() ?? 8 + let includeOff = arguments["includeOffscreen"]?.asBool() ?? false + let roots = (info.displayItems as? [LookinDisplayItem]) ?? [] + let nodes = roots.map { JSONShape.node($0, index: index, maxDepth: depth, includeOffscreen: includeOff) } + return try JSON.encode(["windows": nodes]) + } +} + +// MARK: - search_elements + +struct SearchElementsTool: LookinTool { + let name = "search_elements" + let description = "Find UI elements matching text content, accessibility id, class name, role, and/or visibility. All filters AND together." + var inputSchema: Value { + Schema.obj([ + "text": Schema.prop(Schema.string, description: "Substring match against displayed text / title (case-insensitive)."), + "accessibilityId": Schema.prop(Schema.string, description: "Exact match against accessibilityIdentifier."), + "className": Schema.prop(Schema.string, description: "Substring match against the primary class name (e.g. \"Button\", \"UILabel\")."), + "role": Schema.prop(Schema.string, description: "Semantic role (button|label|image|textInput|textArea|switch|slider|scroll|table|collection|stack|window)."), + "visibleOnly": Schema.prop(Schema.boolean, description: "Restrict to visible elements (not hidden, alpha > 0, non-zero frame)."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let q = ElementQuery( + text: arguments["text"]?.asString(), + accessibilityIdentifier: arguments["accessibilityId"]?.asString(), + className: arguments["className"]?.asString(), + role: arguments["role"]?.asString(), + visibleOnly: arguments["visibleOnly"]?.asBool() ?? false + ) + return try JSON.encode(["matches": ElementSearch.run(q, in: index)]) + } +} + +// MARK: - get_element + +struct GetElementTool: LookinTool { + let name = "get_element" + let description = "Full attributes for a single element. Pass the oid returned by `search_elements` or any node in the hierarchy." + var inputSchema: Value { + Schema.obj(["oid": Schema.prop(Schema.integer, description: "Element oid (from get_hierarchy or search_elements).")], + required: ["oid"]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + guard let oid = arguments["oid"]?.asUInt() else { throw HierarchyProviderError.unsupported("oid is required") } + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + guard let details = try provider.elementDetails(oid: oid), + let item = index.find(oid: oid) else { + return try JSON.encode(["error": "oid not found"]) + } + struct ElementJSON: Codable { + let node: JSONShape.Node + let attributes: [AttributeJSON] + } + struct AttributeJSON: Codable { + let group: String + let identifier: String + let title: String? + let value: String? + } + let attrs: [AttributeJSON] = details.attributeGroups.flatMap { group -> [AttributeJSON] in + let sections = (group.attrSections as? [LookinAttributesSection]) ?? [] + return sections.flatMap { section -> [AttributeJSON] in + ((section.attributes as? [LookinAttribute]) ?? []).map { attr in + AttributeJSON(group: group.identifier as String, + identifier: attr.identifier as String, + title: attr.displayTitle, + value: String(describing: attr.value ?? "nil")) + } + } + } + return try JSON.encode(ElementJSON( + node: JSONShape.node(item, index: index, maxDepth: 0, includeOffscreen: true), + attributes: attrs)) + } +} + +// MARK: - capture_screenshot + +struct CaptureScreenshotTool: LookinTool { + let name = "capture_screenshot" + let description = "Capture the current key-window screenshot as a base64 PNG. Optionally overlay bounding boxes for one or more oids." + var inputSchema: Value { + Schema.obj([ + "highlightOids": .object(["type": .string("array"), "items": Schema.prop(Schema.integer)]), + "drawBounds": Schema.prop(Schema.boolean, description: "Draw frame rectangles for the highlighted oids. Default true."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + guard let image = try provider.screenshot() else { + return try JSON.encode(["error": "no screenshot available"]) + } + let b64 = BugReportBuilder.pngBase64(image) + return try JSON.encode(["mimeType": "image/png", "base64": b64]) + } +} + +// MARK: - highlight_element + +struct HighlightElementTool: LookinTool { + let name = "highlight_element" + let description = "Ask the running app to flash a highlight overlay around an element. Useful for visually confirming the AI agent picked the right view." + var inputSchema: Value { + Schema.obj([ + "oid": Schema.prop(Schema.integer, description: "Element oid."), + "durationMs": Schema.prop(Schema.integer, description: "How long to keep the highlight visible. Default 1500."), + ], required: ["oid"]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + guard let oid = arguments["oid"]?.asUInt() else { throw HierarchyProviderError.unsupported("oid is required") } + let duration = arguments["durationMs"]?.asInt() ?? 1500 + let provider = try (sharedProvider() ?? providerFactory()) + do { + try provider.highlight(oid: oid, durationMs: duration) + return try JSON.encode(["ok": true]) + } catch HierarchyProviderError.unsupported(let why) { + struct OKResult: Codable { let ok: Bool; let reason: String } + return try JSON.encode(OKResult(ok: false, reason: why)) + } + } +} + +// MARK: - diagnose_layout + +struct DiagnoseLayoutTool: LookinTool { + let name = "diagnose_layout" + let description = "Run layout heuristics over the current screen (or a subtree): zero-size views, offscreen children, tiny tap targets, interactive overlaps, hidden-but-interactive." + var inputSchema: Value { + Schema.obj([ + "scope": Schema.prop(Schema.string, description: "\"screen\" (default) or \"oid\""), + "oid": Schema.prop(Schema.integer, description: "Element oid when scope=\"oid\"."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let oid: UInt? = (arguments["scope"]?.asString() == "oid") ? arguments["oid"]?.asUInt() : nil + return try JSON.encode(["findings": LayoutDiagnostics.run(on: index, scopeOid: oid)]) + } +} + +// MARK: - diagnose_accessibility + +struct DiagnoseAccessibilityTool: LookinTool { + let name = "diagnose_accessibility" + let description = "Run accessibility heuristics: missing labels on interactive elements, duplicate labels, undersized touch targets." + var inputSchema: Value { + Schema.obj([ + "scope": Schema.prop(Schema.string, description: "\"screen\" (default) or \"oid\""), + "oid": Schema.prop(Schema.integer, description: "Element oid when scope=\"oid\"."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let oid: UInt? = (arguments["scope"]?.asString() == "oid") ? arguments["oid"]?.asUInt() : nil + return try JSON.encode(["findings": AccessibilityDiagnostics.run(on: index, scopeOid: oid)]) + } +} + +// MARK: - export_bug_report + +struct ExportBugReportTool: LookinTool { + let name = "export_bug_report" + let description = "Bundle app+device metadata, hierarchy, screenshot, and all diagnostic findings into one JSON object suitable for pasting into an issue." + var inputSchema: Value { + Schema.obj([ + "includeScreenshot": Schema.prop(Schema.boolean, description: "Embed the base64 PNG screenshot. Default true."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let includeScreenshot = arguments["includeScreenshot"]?.asBool() ?? true + let report = try BugReportBuilder.build(provider: provider, includeScreenshot: includeScreenshot) + return try JSON.encode(report) + } +} diff --git a/Sources/LookinMCPServer/main.swift b/Sources/LookinMCPServer/main.swift new file mode 100644 index 0000000..0d2096c --- /dev/null +++ b/Sources/LookinMCPServer/main.swift @@ -0,0 +1,6 @@ +import Foundation +import LookinMCPCore + +let args = Array(CommandLine.arguments.dropFirst()) +let exitCode = await CLI.dispatch(args) +exit(exitCode) diff --git a/Tests/LookinMCPCoreTests/DiagnosticsTests.swift b/Tests/LookinMCPCoreTests/DiagnosticsTests.swift new file mode 100644 index 0000000..2bee1a3 --- /dev/null +++ b/Tests/LookinMCPCoreTests/DiagnosticsTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import LookinMCPCore + +final class DiagnosticsTests: XCTestCase { + func testSmallTapTargetFindingOnSmallButton() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let layout = LayoutDiagnostics.run(on: index) + XCTAssertTrue(layout.contains { $0.code == "layout.tap_target_small" && $0.oid == 4 }, + "Expected small-tap-target finding on the 20×20 button.") + } + + func testOffscreenLabelDetected() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let layout = LayoutDiagnostics.run(on: index) + XCTAssertTrue(layout.contains { $0.code == "layout.offscreen_of_parent" && $0.oid == 6 }, + "Offscreen label at (5000,5000) should be flagged.") + } + + func testMissingAccessibilityLabelOnButton() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let a11y = AccessibilityDiagnostics.run(on: index) + XCTAssertTrue(a11y.contains { $0.code == "a11y.missing_label" && $0.oid == 4 }, + "Tiny button has empty label — expected a11y warning.") + } +} diff --git a/Tests/LookinMCPCoreTests/ElementSearchTests.swift b/Tests/LookinMCPCoreTests/ElementSearchTests.swift new file mode 100644 index 0000000..66da5df --- /dev/null +++ b/Tests/LookinMCPCoreTests/ElementSearchTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import LookinMCPCore + +final class ElementSearchTests: XCTestCase { + func testFindByText() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(text: "hello"), in: index) + XCTAssertEqual(hits.count, 1) + XCTAssertEqual(hits.first?.oid, 3) + } + + func testFindByRole() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(role: "button"), in: index) + XCTAssertEqual(hits.first?.oid, 4) + } + + func testFindByClassName() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(className: "Label"), in: index) + XCTAssertGreaterThanOrEqual(hits.count, 2) + } + + func testVisibleOnlyHidesZeroSizeOrOffscreen() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let withHidden = ElementSearch.run(ElementQuery(className: "Label", visibleOnly: false), in: index) + let visibleOnly = ElementSearch.run(ElementQuery(className: "Label", visibleOnly: true), in: index) + // The offscreen label has positive area, so visibleOnly does NOT filter it out + // (offscreen-by-position is a layout concern, not a visibility one). Both queries + // return the same set here — the test guards against accidental filtering changes. + XCTAssertEqual(withHidden.count, visibleOnly.count) + } +} diff --git a/Tests/LookinMCPCoreTests/Fixtures.swift b/Tests/LookinMCPCoreTests/Fixtures.swift new file mode 100644 index 0000000..cbc2d50 --- /dev/null +++ b/Tests/LookinMCPCoreTests/Fixtures.swift @@ -0,0 +1,72 @@ +import Foundation +import CoreGraphics +import LookinCore + +/// Deterministic in-memory fixtures so tests don't need a `.lookin` archive on +/// disk. The shapes mirror what `LookinHierarchyInfo` looks like after +/// deserialization from a real device, but every field that tests touch is +/// directly set on the ObjC objects. +enum Fixtures { + static func simpleScreen() -> LookinHierarchyInfo { + let window = item(class: "UIWindow", frame: CGRect(x: 0, y: 0, width: 390, height: 844), oid: 1) + let container = item(class: "UIView", frame: CGRect(x: 0, y: 88, width: 390, height: 700), oid: 2) + let label = item(class: "UILabel", frame: CGRect(x: 16, y: 16, width: 358, height: 22), oid: 3, + attributes: [("text", "Hello"), ("accessibilityLabel", "Hello")]) + let smallButton = item(class: "UIButton", frame: CGRect(x: 100, y: 80, width: 20, height: 20), oid: 4, + attributes: [("accessibilityLabel", "")]) + let secureField = item(class: "UITextField", frame: CGRect(x: 16, y: 200, width: 358, height: 44), oid: 5, + attributes: [("text", "supersecret"), ("isSecureTextEntry", NSNumber(value: true))]) + let offscreen = item(class: "UILabel", frame: CGRect(x: 5000, y: 5000, width: 100, height: 22), oid: 6, + attributes: [("text", "Way off")]) + container.subitems = [label, smallButton, secureField, offscreen] + window.subitems = [container] + let info = LookinHierarchyInfo() + info.displayItems = [window] + info.appInfo = makeApp() + info.serverVersion = 9 + return info + } + + static func makeApp() -> LookinAppInfo { + let app = LookinAppInfo() + app.appName = "FixtureApp" + app.appBundleIdentifier = "test.fixture" + app.deviceDescription = "iPhone 15 Pro" + app.osDescription = "iOS 17.4" + app.screenWidth = 390; app.screenHeight = 844; app.screenScale = 3 + app.serverVersion = 9 + return app + } + + static func item(class className: String, + frame: CGRect, + oid: unsignedlong, + attributes: [(String, Any)] = []) -> LookinDisplayItem { + let item = LookinDisplayItem() + item.frame = frame + item.bounds = CGRect(origin: .zero, size: frame.size) + item.alpha = 1 + let view = LookinObject() + view.oid = oid + view.classChainList = [className, "UIView", "NSObject"] + item.viewObject = view + if !attributes.isEmpty { + let group = LookinAttributesGroup() + group.identifier = "lookin.fixture" + let section = LookinAttributesSection() + section.identifier = "fixture" + section.attributes = attributes.map { (id, val) in + let a = LookinAttribute() + a.identifier = id + a.value = val + return a + } + group.attrSections = [section] + item.attributesGroupList = [group] + } + return item + } +} + +// Swift can't see `unsigned long` from ObjC headers as a literal type alias; bridge it. +typealias unsignedlong = UInt diff --git a/Tests/LookinMCPCoreTests/Fixtures/.keep b/Tests/LookinMCPCoreTests/Fixtures/.keep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift b/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift new file mode 100644 index 0000000..a4c4de1 --- /dev/null +++ b/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import LookinMCPCore +import LookinCore + +final class HierarchyIndexTests: XCTestCase { + func testFlatCountMatchesRecursiveWalk() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + var dfsCount = 0 + index.walkAll { _ in dfsCount += 1 } + XCTAssertEqual(dfsCount, index.count) + XCTAssertGreaterThan(dfsCount, 0) + } + + func testFindByOidReturnsSameNodeAsDFS() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + var collected: [(UInt, LookinDisplayItem)] = [] + index.walkAll { item in + if let oid = HierarchyIndex.oid(of: item) { collected.append((oid, item)) } + } + for (oid, item) in collected { + XCTAssertTrue(index.find(oid: oid) === item, "Lookup mismatch for oid \(oid)") + } + } + + func testAncestorChain() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + // The deepest button should have a non-empty ancestor chain. + var deepest: UInt? + index.walkAll { item in + if JSONShape.primaryClassName(item).hasSuffix("Button"), + let oid = HierarchyIndex.oid(of: item) { + deepest = oid + } + } + XCTAssertNotNil(deepest) + XCTAssertFalse(index.ancestorOids(of: deepest!).isEmpty) + } +} diff --git a/Tests/LookinMCPCoreTests/JSONShapeTests.swift b/Tests/LookinMCPCoreTests/JSONShapeTests.swift new file mode 100644 index 0000000..ed47284 --- /dev/null +++ b/Tests/LookinMCPCoreTests/JSONShapeTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import LookinMCPCore +import LookinCore + +final class JSONShapeTests: XCTestCase { + func testSecureFieldTextIsRedacted() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + let secure = index.find(oid: 5)! + let node = JSONShape.node(secure, index: index, maxDepth: 0, includeOffscreen: true) + XCTAssertEqual(node.className, "UITextField") + XCTAssertNil(node.text, "Secure text field contents must never leak into JSONShape output.") + } + + func testNonSecureLabelTextSurvives() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + let label = index.find(oid: 3)! + let node = JSONShape.node(label, index: index, maxDepth: 0, includeOffscreen: true) + XCTAssertEqual(node.text, "Hello") + } + + func testRoleInferenceForCommonClasses() { + XCTAssertEqual(JSONShape.inferRole(className: "UIButton"), "button") + XCTAssertEqual(JSONShape.inferRole(className: "MyFancyButton"), "button") + XCTAssertEqual(JSONShape.inferRole(className: "UILabel"), "label") + XCTAssertNil(JSONShape.inferRole(className: "RandomView")) + } +} diff --git a/Tests/LookinMCPCoreTests/ProviderErrorTests.swift b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift new file mode 100644 index 0000000..0bd2155 --- /dev/null +++ b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import LookinMCPCore + +final class ProviderErrorTests: XCTestCase { + func testNoTargetAppErrorMessage() { + let err = HierarchyProviderError.noTargetApp + XCTAssertTrue(err.description.contains("Debug build")) + } + + func testFileProviderReturnsUnsupportedForHighlight() { + let info = Fixtures.simpleScreen() + let provider = FileHierarchyProvider(info: info) + XCTAssertThrowsError(try provider.highlight(oid: 3, durationMs: 1000)) { err in + guard case HierarchyProviderError.unsupported = err else { + XCTFail("Expected .unsupported, got \(err)") + return + } + } + } +} diff --git a/docs/mcp-client-configs.md b/docs/mcp-client-configs.md new file mode 100644 index 0000000..9e1a203 --- /dev/null +++ b/docs/mcp-client-configs.md @@ -0,0 +1,85 @@ +# Connecting `lookinside-mcp` to MCP clients + +Always start with `lookinside-mcp print-config ` — it prints the snippet below with the binary's absolute path filled in. + +## Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] + } + } +} +``` + +Restart Claude Desktop. The 🔌 indicator should show `lookinside` connected. + +## Claude Code + +```sh +claude mcp add lookinside /absolute/path/to/lookinside-mcp serve +``` + +## Cursor + +Edit `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] + } + } +} +``` + +## Windsurf + +Edit `~/.codeium/windsurf/mcp_config.json` with the same shape as Cursor. + +## VS Code (Copilot Chat / Continue / Claude extension) + +Under your MCP-aware extension's config block: + +```json +"lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] +} +``` + +## Custom — anything that speaks MCP + +`lookinside-mcp serve` reads JSON-RPC framed by newlines on stdin and writes responses to stdout. Stderr is reserved for human-readable diagnostics. No environment variables are required. + +## Example prompts + +After connecting, try: + +- "Inspect the current screen using lookinside and summarize the layout." +- "Find every UILabel with empty text on this screen." +- "Run accessibility diagnostics and propose fixes." +- "Export a bug report with screenshot for the current screen." +- "Highlight the element with text 'Continue'." + +## Offline / snapshot mode + +To analyze a captured `.lookin` snapshot: + +```json +{ + "mcpServers": { + "lookinside-offline": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve", "--snapshot", "/abs/path/to/snapshot.lookin"] + } + } +} +``` diff --git a/docs/mcp-troubleshooting.md b/docs/mcp-troubleshooting.md new file mode 100644 index 0000000..e50a0a6 --- /dev/null +++ b/docs/mcp-troubleshooting.md @@ -0,0 +1,63 @@ +# Troubleshooting `lookinside-mcp` + +Start by running: + +```sh +lookinside-mcp health +``` + +It reports what `lookinside-mcp serve` will see when an MCP client invokes it. + +## `status: no_target` + +No Debug build with `LookinServer` is reachable. Common causes: + +- **App not running in Debug.** Release builds don't embed `LookinServer`. Check your scheme. +- **`LookinServer` not added.** SPM: depend on the `LookinServer` library; CocoaPods: add the `LookinServer` subspec. See the main [README](../README.md). +- **App is in the background.** `LookinServer` won't service requests while the app is suspended. +- **Wrong Simulator.** Ports are shared across all simulators on a Mac; if multiple sims run apps with `LookinServer`, the first 6 (47164–47169) get one port each. `lookinside-mcp list_apps` shows every reachable app. +- **USB device unlocked?** Physical-device support requires `usbmuxd` (built into Xcode) and an unlocked device. + +## `decodeFailure: protocol version` + +`LookinServer` was built against an older protocol. Update the dependency in your app target to match the LookInside version this MCP server ships from. + +## `timeout` + +The app responded slowly. Causes: +- Paused on a breakpoint in Xcode — resume. +- Main thread blocked — investigate. +- App in background — bring to foreground. + +## Tool calls return `{ "ok": false, "reason": "highlight requires …" }` + +`highlight_element` requires a server-side request type that hasn't shipped yet. The tool degrades gracefully so the agent can still recommend a fix. Track the follow-up issue in the repo. + +## Client doesn't see the server + +- Confirm the absolute path in your config — `lookinside-mcp print-config ` always emits the correct path. +- Restart the client after editing config. +- Tail stderr: most MCP clients capture stderr to a log file. `lookinside-mcp` writes a "listening on stdio" banner there at startup. +- If you see `Fatal:` on stderr, copy the full text — it includes the underlying error. + +## Codesign / Gatekeeper + +If you downloaded a release binary, macOS may quarantine it: + +```sh +xattr -dr com.apple.quarantine /path/to/lookinside-mcp +``` + +The release script signs and notarizes builds, but a `curl` download still picks up the quarantine bit until you remove it. + +## Firewall + +Loopback Peertalk traffic is not blocked by the macOS firewall in any default configuration. If you've added a custom outbound rule, allow `lookinside-mcp` to connect to `127.0.0.1` on ports 47164–47179. + +## Last resort + +`lookinside-mcp` is intentionally small. If something is weird: + +1. Reproduce with `lookinside-mcp health` (no MCP client involved). +2. Then try `lookinside-mcp serve --snapshot some.lookin` to confirm the MCP plumbing works against a static snapshot. +3. File an issue with both outputs and the LookInside / LookinServer versions. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..cb2f329 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,96 @@ +# MCP integration (Debug-only) + +LookInside ships an optional MCP server, `lookinside-mcp`, that lets AI coding agents inspect a running Debug build's UI through the same plumbing the macOS LookInside.app uses. Once installed, any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, continue.dev — can ask the agent things like: + +- *"Inspect the current screen and tell me what UI issues you see."* +- *"Why is this button not visible?"* +- *"Find clipped labels on this screen."* +- *"Highlight the checkout button."* +- *"Check accessibility problems on the current screen."* +- *"Export a bug report for this UI state."* + +## What it can do + +| Tool | Purpose | +|---|---| +| `health_check` | Is a Debug app reachable? Returns version + connected-app metadata. | +| `list_apps` | Every Debug app currently reachable on Peertalk ports. | +| `current_screen` | Quick screen summary with a depth-2 hierarchy preview. | +| `get_hierarchy` | Full hierarchy tree (configurable depth). | +| `search_elements` | Filter by text, accessibility id, class, role, visibility. | +| `get_element` | Full attribute groups for one oid. | +| `capture_screenshot` | Base64 PNG of the key window. | +| `highlight_element` | Flash a highlight overlay in-app. | +| `diagnose_layout` | Heuristics: zero-size views, tap targets < 44pt, overlapping interactives, offscreen children. | +| `diagnose_accessibility` | Missing labels, duplicates, small touch targets. | +| `export_bug_report` | Bundle screen + hierarchy + diagnostics + screenshot into one JSON. | + +## Debug-only by design + +`lookinside-mcp` works only against apps that embed `LookinServer`, which is a Debug-only library. Release builds simply have nothing to talk to. The server enforces no proprietary handshake — but the client refuses any operation beyond hierarchy reads: + +- No arbitrary selector invocation. +- No shell exec. +- Secure text field contents (`UITextField.isSecureTextEntry`, `NSSecureTextField`) are redacted at the data layer — they cannot leak through any current or future tool. +- Transport is stdio only; no network listener is opened. + +## Install + +### Build from source + +```sh +./Scripts/build-mcp-server.sh +``` + +Drops the binary at `./build/lookinside-mcp`. Add it to `PATH`, or reference the absolute path in your client config. + +### From a release artifact + +Download the latest `lookinside-mcp` binary from [GitHub Releases](../README.md) and `chmod +x` it. + +## Connect a client + +Run `lookinside-mcp print-config ` to get a ready-to-paste snippet. See [`mcp-client-configs.md`](mcp-client-configs.md) for client-specific instructions. + +## Verify + +```sh +lookinside-mcp health +``` + +Should print `status: ok` and one or more reachable apps. If it prints `status: no_target`, see [`mcp-troubleshooting.md`](mcp-troubleshooting.md). + +## Architecture + +``` +AI agent ↔ MCP client (Claude Desktop, etc.) + │ stdio JSON-RPC + ▼ + lookinside-mcp ──────────► Peertalk TCP (47164–47179) + │ │ + └── LookinMCPCore ▼ + (hierarchy index, LookinServer (in-process + search, diagnostics, in your Debug build) + bug-report builder) +``` + +The MCP server is a parallel consumer of `LookinServer` alongside the macOS LookInside.app — both speak the same protocol, but the MCP server skips the macOS app's license gate because that gate is enforced client-side, not by the in-process server. + +## Limitations (today) + +- `highlight_element` requires a server-side request type that doesn't exist yet — coming in a follow-up. +- No write-side tools (tap, scroll, type, temporary property changes). The protocol supports them; we deliberately gated them out of the first version. +- Source-code mapping (oid → file:line) ships when SwiftUI trace data is stable enough to rely on. + +## Known errors + +| Error | What it means | +|---|---| +| `noTargetApp` | No Debug build with `LookinServer` was reachable. Launch one and retry. | +| `timeout` | The app responded slowly — usually paused at a breakpoint or in background. | +| `decodeFailure` | Protocol version mismatch. Update `LookinServer` in your app. | +| `unsupported` | A tool isn't implemented for this provider (e.g. highlight from a snapshot file). | + +## Offline / snapshot mode + +`lookinside-mcp serve --snapshot path/to/screen.lookin` serves an exported `.lookin` snapshot. Useful for analyzing captured bug states without keeping the app running. From d4320664271fae3749859dfe02fb8fd135d08912 Mon Sep 17 00:00:00 2001 From: tastyheadphones Date: Thu, 14 May 2026 11:16:30 +0900 Subject: [PATCH 2/2] Add codex print-config target and fix claude-code add syntax Codex CLI and Claude Code both use the canonical ` mcp add [--env K=V] -- [args]` form. The previous claude-code snippet omitted the `--` separator, which works in practice but isn't the documented syntax. Aligns both clients on the same shape and documents the env-var passthrough. --- Sources/LookinMCPServer/CLI.swift | 2 +- Sources/LookinMCPServer/PrintConfigCommand.swift | 11 ++++++++--- docs/mcp-client-configs.md | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Sources/LookinMCPServer/CLI.swift b/Sources/LookinMCPServer/CLI.swift index 3d2c5eb..d366a1c 100644 --- a/Sources/LookinMCPServer/CLI.swift +++ b/Sources/LookinMCPServer/CLI.swift @@ -40,7 +40,7 @@ enum CLI { Print connection status and exit nonzero if no Debug build is reachable. lookinside-mcp print-config Print a ready-to-paste config snippet for one of: - claude-desktop | claude-code | cursor | windsurf | vscode + claude-desktop | claude-code | codex | cursor | windsurf | vscode FLAGS --version, -V Print version and exit. diff --git a/Sources/LookinMCPServer/PrintConfigCommand.swift b/Sources/LookinMCPServer/PrintConfigCommand.swift index 836bce1..af96fce 100644 --- a/Sources/LookinMCPServer/PrintConfigCommand.swift +++ b/Sources/LookinMCPServer/PrintConfigCommand.swift @@ -23,8 +23,13 @@ enum PrintConfigCommand { """ case "claude-code": snippet = """ - # Run once: - claude mcp add lookinside \(binary) serve + # Run once. Use --env KEY=VALUE before `--` to pass environment variables. + claude mcp add lookinside -- \(binary) serve + """ + case "codex": + snippet = """ + # Run once. Use --env KEY=VALUE before `--` to pass environment variables. + codex mcp add lookinside -- \(binary) serve """ case "cursor": snippet = """ @@ -53,7 +58,7 @@ enum PrintConfigCommand { } """ default: - FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, cursor, windsurf, vscode.\n".utf8)) + FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, codex, cursor, windsurf, vscode.\n".utf8)) return 2 } print(snippet) diff --git a/docs/mcp-client-configs.md b/docs/mcp-client-configs.md index 9e1a203..0405072 100644 --- a/docs/mcp-client-configs.md +++ b/docs/mcp-client-configs.md @@ -22,9 +22,23 @@ Restart Claude Desktop. The 🔌 indicator should show `lookinside` connected. ## Claude Code ```sh -claude mcp add lookinside /absolute/path/to/lookinside-mcp serve +claude mcp add lookinside -- /absolute/path/to/lookinside-mcp serve ``` +Pass environment variables with `--env KEY=VALUE` before the `--`: + +```sh +claude mcp add lookinside --env LOOKIN_LOG=debug -- /absolute/path/to/lookinside-mcp serve +``` + +## Codex CLI + +```sh +codex mcp add lookinside -- /absolute/path/to/lookinside-mcp serve +``` + +Same `--env KEY=VALUE` flag is supported before `--`. + ## Cursor Edit `~/.cursor/mcp.json`: