mcp-baepsae 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -259,6 +259,11 @@ func requireSimulatorUdid(_ target: TargetApp) throws -> String {
259
259
  return udid
260
260
  }
261
261
 
262
+ func simulatorUdid(from target: TargetApp) -> String? {
263
+ guard case .simulator(let udid) = target else { return nil }
264
+ return udid
265
+ }
266
+
262
267
  // MARK: - Accessibility Helpers
263
268
 
264
269
  func ensureAccessibilityTrusted() throws {
@@ -300,6 +305,24 @@ func simulatorAccessibilityRootElement() throws -> UIElement {
300
305
  return appElement
301
306
  }
302
307
 
308
+ func shellCaptureCommand(_ command: String, _ arguments: [String]) -> String? {
309
+ let process = Process()
310
+ process.executableURL = URL(fileURLWithPath: command)
311
+ process.arguments = arguments
312
+ let pipe = Pipe()
313
+ process.standardOutput = pipe
314
+ process.standardError = FileHandle.nullDevice
315
+ do {
316
+ try process.run()
317
+ process.waitUntilExit()
318
+ guard process.terminationStatus == 0 else { return nil }
319
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
320
+ return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
321
+ } catch {
322
+ return nil
323
+ }
324
+ }
325
+
303
326
  func CopyAttributeValue(_ element: UIElement, _ attribute: CFString) -> CFTypeRef? {
304
327
  var value: CFTypeRef?
305
328
  let status = AXUIElementCopyAttributeValue(element, attribute, &value)
@@ -434,6 +457,24 @@ func IdentifierAttribute(_ element: UIElement) -> String? {
434
457
  return StringAttribute(element, "AXIdentifier" as CFString)
435
458
  }
436
459
 
460
+ func performPrimaryAction(on element: UIElement) throws {
461
+ let actions = ActionNames(element)
462
+ if actions.contains(kAXPressAction as String) {
463
+ let status = AXUIElementPerformAction(element, kAXPressAction as CFString)
464
+ if status != .success {
465
+ throw NativeError.commandFailed("Matched accessibility element but AXPress failed with status \(status.rawValue).")
466
+ }
467
+ return
468
+ }
469
+
470
+ if let frame = FrameAttribute(element) {
471
+ sendClick(at: CGPoint(x: frame.midX, y: frame.midY))
472
+ return
473
+ }
474
+
475
+ throw NativeError.commandFailed("Matched accessibility element has no AXPress action or frame for fallback click.")
476
+ }
477
+
437
478
  // MARK: - Text Helpers
438
479
 
439
480
  func normalizeText(_ value: String) -> String {
@@ -540,6 +581,98 @@ func describeAccessibilityElement(_ element: UIElement, includeEmpty: Bool = tru
540
581
  return parts.joined(separator: " ")
541
582
  }
542
583
 
584
+ func actionableTabBarItems(in tabBar: UIElement, maxDepth: Int = 2) -> [UIElement] {
585
+ var stack: [(element: UIElement, depth: Int)] = Children(tabBar).map { ($0, 1) }.reversed()
586
+ var matches: [UIElement] = []
587
+
588
+ while let current = stack.popLast() {
589
+ let role = StringAttribute(current.element, kAXRoleAttribute as CFString) ?? ""
590
+ let actions = ActionNames(current.element)
591
+ let hasFrame = FrameAttribute(current.element) != nil
592
+ let isLikelyTabItem =
593
+ role == "AXButton" ||
594
+ role == "AXRadioButton" ||
595
+ role == "AXCheckBox" ||
596
+ actions.contains(kAXPressAction as String)
597
+
598
+ if isLikelyTabItem && hasFrame {
599
+ if !matches.contains(where: { elementsAreEqual($0, current.element) }) {
600
+ matches.append(current.element)
601
+ }
602
+ }
603
+
604
+ if current.depth < maxDepth {
605
+ for child in Children(current.element).reversed() {
606
+ stack.append((child, current.depth + 1))
607
+ }
608
+ }
609
+ }
610
+
611
+ return matches.sorted {
612
+ (FrameAttribute($0)?.midX ?? 0) < (FrameAttribute($1)?.midX ?? 0)
613
+ }
614
+ }
615
+
616
+ func semanticProxyTabButtons(in contentRoot: UIElement, excluding excludedElement: UIElement? = nil, expectedCount: Int) -> [UIElement] {
617
+ guard expectedCount > 0 else { return [] }
618
+ guard let contentFrame = FrameAttribute(contentRoot) else { return [] }
619
+ let excludedFrame = excludedElement.flatMap(FrameAttribute)
620
+
621
+ let directChildren = Children(contentRoot)
622
+ let candidates = directChildren.filter { child in
623
+ let role = StringAttribute(child, kAXRoleAttribute as CFString) ?? ""
624
+ let actions = ActionNames(child)
625
+ guard let frame = FrameAttribute(child) else { return false }
626
+ if let excludedFrame, excludedFrame.contains(frame) {
627
+ return false
628
+ }
629
+ guard frame.midY < contentFrame.origin.y + contentFrame.height * 0.55 else {
630
+ return false
631
+ }
632
+ guard frame.width < contentFrame.width * 0.8 else {
633
+ return false
634
+ }
635
+ return role == "AXButton" || actions.contains(kAXPressAction as String)
636
+ }
637
+
638
+ guard !candidates.isEmpty else { return [] }
639
+
640
+ struct RowGroup {
641
+ var meanY: CGFloat
642
+ var elements: [UIElement]
643
+ }
644
+
645
+ var rows: [RowGroup] = []
646
+ let tolerance: CGFloat = 24
647
+ for element in candidates.sorted(by: { (FrameAttribute($0)?.midY ?? 0) < (FrameAttribute($1)?.midY ?? 0) }) {
648
+ let midY = FrameAttribute(element)?.midY ?? 0
649
+ if let rowIndex = rows.firstIndex(where: { abs($0.meanY - midY) <= tolerance }) {
650
+ rows[rowIndex].elements.append(element)
651
+ let count = CGFloat(rows[rowIndex].elements.count)
652
+ rows[rowIndex].meanY = ((rows[rowIndex].meanY * (count - 1)) + midY) / count
653
+ } else {
654
+ rows.append(RowGroup(meanY: midY, elements: [element]))
655
+ }
656
+ }
657
+
658
+ guard let bestRow = rows.sorted(by: {
659
+ if $0.elements.count == $1.elements.count {
660
+ return $0.meanY < $1.meanY
661
+ }
662
+ return $0.elements.count > $1.elements.count
663
+ }).first else {
664
+ return []
665
+ }
666
+
667
+ guard bestRow.elements.count == expectedCount else {
668
+ return []
669
+ }
670
+
671
+ return bestRow.elements.sorted {
672
+ (FrameAttribute($0)?.midX ?? 0) < (FrameAttribute($1)?.midX ?? 0)
673
+ }
674
+ }
675
+
543
676
  func describeAccessibilityTree(from root: UIElement, options: DescribeOptions = DescribeOptions()) -> [String] {
544
677
  var lines: [String] = []
545
678
  var stack: [(element: UIElement, depth: Int)] = [(root, 0)]
@@ -869,7 +1002,57 @@ func findElementBySubrole(from root: UIElement, subrole: String) -> UIElement? {
869
1002
  return nil
870
1003
  }
871
1004
 
872
- func simulatorContentRootElement(from appRoot: UIElement) -> UIElement? {
1005
+ func simulatorDeviceName(for udid: String?) -> String? {
1006
+ guard let udid, !udid.isEmpty else { return nil }
1007
+ return shellCaptureCommand("/usr/bin/xcrun", ["simctl", "getenv", udid, "SIMULATOR_DEVICE_NAME"])
1008
+ }
1009
+
1010
+ func simulatorWindowTitle(_ element: UIElement) -> String? {
1011
+ return StringAttribute(element, kAXTitleAttribute as CFString)
1012
+ ?? StringAttribute(element, kAXDescriptionAttribute as CFString)
1013
+ ?? StringAttribute(element, kAXValueAttribute as CFString)
1014
+ }
1015
+
1016
+ func simulatorWindowElement(from appRoot: UIElement, udid: String? = nil) -> UIElement? {
1017
+ let windows = Children(appRoot).filter { element in
1018
+ let attrs = copyMultipleAttributes(element, [kAXRoleAttribute as String])
1019
+ if let ref = attrs[kAXRoleAttribute as String], let role = stringFromCFTypeRef(ref) {
1020
+ return role == "AXWindow"
1021
+ }
1022
+ return false
1023
+ }
1024
+
1025
+ guard !windows.isEmpty else { return nil }
1026
+
1027
+ let normalizedDeviceName = simulatorDeviceName(for: udid).map(normalizeText)
1028
+ let preferredWindows: [UIElement]
1029
+ if let normalizedDeviceName {
1030
+ let matched = windows.filter { window in
1031
+ guard let title = simulatorWindowTitle(window) else { return false }
1032
+ return normalizeText(title).contains(normalizedDeviceName)
1033
+ }
1034
+ preferredWindows = matched.isEmpty ? windows : matched
1035
+ } else {
1036
+ preferredWindows = windows
1037
+ }
1038
+
1039
+ var bestWindow: UIElement?
1040
+ var bestArea: CGFloat = 0
1041
+ for window in preferredWindows {
1042
+ let area = FrameAttribute(window).map { $0.width * $0.height } ?? 0
1043
+ if bestWindow == nil || area > bestArea {
1044
+ bestWindow = window
1045
+ bestArea = area
1046
+ }
1047
+ }
1048
+ return bestWindow
1049
+ }
1050
+
1051
+ func simulatorContentRootElement(from appRoot: UIElement, udid: String? = nil) -> UIElement? {
1052
+ if let scopedWindow = simulatorWindowElement(from: appRoot, udid: udid),
1053
+ let scopedContentRoot = findElementBySubrole(from: scopedWindow, subrole: "iOSContentGroup") {
1054
+ return scopedContentRoot
1055
+ }
873
1056
  return findElementBySubrole(from: appRoot, subrole: "iOSContentGroup")
874
1057
  }
875
1058
 
@@ -956,7 +1139,8 @@ func collectWideAuxiliaryGroups(in root: UIElement, contentRootFrame: CGRect? =
956
1139
  )
957
1140
  }
958
1141
 
959
- func simulatorAuxiliaryContainerCandidates(from appRoot: UIElement, excluding contentRoot: UIElement? = nil) -> [SimulatorAuxiliaryContainerCandidate] {
1142
+ func simulatorAuxiliaryContainerCandidates(from appRoot: UIElement, excluding contentRoot: UIElement? = nil, udid: String? = nil) -> [SimulatorAuxiliaryContainerCandidate] {
1143
+ let scopeRoot = simulatorWindowElement(from: appRoot, udid: udid) ?? appRoot
960
1144
  let contentRootFrame = contentRoot.flatMap(FrameAttribute)
961
1145
  let roleCandidates: [(role: String, label: String)] = [
962
1146
  ("AXTabGroup", "tab bar"),
@@ -981,20 +1165,20 @@ func simulatorAuxiliaryContainerCandidates(from appRoot: UIElement, excluding co
981
1165
  }
982
1166
 
983
1167
  for roleCandidate in roleCandidates {
984
- for element in collectElementsByRole(in: appRoot, role: roleCandidate.role) {
1168
+ for element in collectElementsByRole(in: scopeRoot, role: roleCandidate.role) {
985
1169
  appendCandidate(element, label: roleCandidate.label)
986
1170
  }
987
1171
  }
988
1172
 
989
- for element in collectWideAuxiliaryGroups(in: appRoot, contentRootFrame: contentRootFrame) {
1173
+ for element in collectWideAuxiliaryGroups(in: scopeRoot, contentRootFrame: contentRootFrame) {
990
1174
  appendCandidate(element, label: "auxiliary group")
991
1175
  }
992
1176
 
993
1177
  return candidates
994
1178
  }
995
1179
 
996
- func simulatorAuxiliaryContainerLabels(from appRoot: UIElement, excluding contentRoot: UIElement? = nil) -> [String] {
997
- simulatorAuxiliaryContainerCandidates(from: appRoot, excluding: contentRoot).map(\.label)
1180
+ func simulatorAuxiliaryContainerLabels(from appRoot: UIElement, excluding contentRoot: UIElement? = nil, udid: String? = nil) -> [String] {
1181
+ simulatorAuxiliaryContainerCandidates(from: appRoot, excluding: contentRoot, udid: udid).map(\.label)
998
1182
  }
999
1183
 
1000
1184
  func formatSimulatorAuxiliaryContainerHint(_ labels: [String]) -> String? {
@@ -1018,7 +1202,7 @@ func simulatorSelectorNotFoundMessage(selectorText: String, auxiliaryLabels: [St
1018
1202
  return "No accessibility element matched \(selectorText) in simulator app content. Try --all to include Simulator chrome UI."
1019
1203
  }
1020
1204
 
1021
- func findTabBarElement(in root: UIElement) -> UIElement? {
1205
+ func findTabBarElement(in root: UIElement, simulatorUdid: String? = nil) -> UIElement? {
1022
1206
  // 1st pass: Look for AXTabGroup
1023
1207
  var stack: [UIElement] = [root]
1024
1208
  var visited = 0
@@ -1051,7 +1235,59 @@ func findTabBarElement(in root: UIElement) -> UIElement? {
1051
1235
  }
1052
1236
  }
1053
1237
 
1054
- // 3rd pass: Heuristicwide AXGroup in bottom 15% of screen
1238
+ // 3rd pass: Simulator-specific heuristic look for a wide bottom group
1239
+ // inside iOSContentGroup. SwiftUI TabView on Simulator frequently exposes
1240
+ // the tab bar as AXGroup text="Tab Bar" rather than AXTabGroup.
1241
+ if let contentRoot = simulatorContentRootElement(from: root, udid: simulatorUdid),
1242
+ let contentFrame = FrameAttribute(contentRoot) {
1243
+ let bottomThresholdY = contentFrame.origin.y + contentFrame.height * 0.65
1244
+ stack = [contentRoot]
1245
+ visited = 0
1246
+ while let current = stack.popLast() {
1247
+ if visited > 800 { break }
1248
+ visited += 1
1249
+
1250
+ let attrs = copyMultipleAttributes(current, [
1251
+ kAXRoleAttribute as String,
1252
+ "AXFrame",
1253
+ "AXLabel",
1254
+ kAXTitleAttribute as String,
1255
+ kAXDescriptionAttribute as String,
1256
+ kAXValueAttribute as String,
1257
+ ])
1258
+
1259
+ if let ref = attrs[kAXRoleAttribute as String],
1260
+ let role = stringFromCFTypeRef(ref),
1261
+ role == "AXGroup",
1262
+ let frameRef = attrs["AXFrame"],
1263
+ let frame = frameFromCFTypeRef(frameRef) {
1264
+ let textCandidates = [
1265
+ attrs["AXLabel"],
1266
+ attrs[kAXTitleAttribute as String],
1267
+ attrs[kAXDescriptionAttribute as String],
1268
+ attrs[kAXValueAttribute as String],
1269
+ ].compactMap { $0 }.compactMap(stringFromCFTypeRef)
1270
+
1271
+ let hasExplicitTabBarText = textCandidates.contains { candidate in
1272
+ normalizeText(candidate).contains("tab bar")
1273
+ }
1274
+
1275
+ let isWide = frame.width >= contentFrame.width * 0.60
1276
+ let isNearBottom = frame.origin.y >= bottomThresholdY && frame.maxY <= contentFrame.maxY + 8
1277
+ let plausibleBarHeight = frame.height >= 32 && frame.height <= 140
1278
+
1279
+ if hasExplicitTabBarText || (isWide && isNearBottom && plausibleBarHeight) {
1280
+ return current
1281
+ }
1282
+ }
1283
+
1284
+ for child in Children(current).reversed() {
1285
+ stack.append(child)
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // 4th pass: Generic heuristic — wide AXGroup in bottom 15% of screen
1055
1291
  guard let mainScreen = NSScreen.main else { return nil }
1056
1292
  let screenHeight = mainScreen.frame.height
1057
1293
  let bottomThreshold = screenHeight * 0.15
@@ -1086,8 +1322,8 @@ func findTabBarElement(in root: UIElement) -> UIElement? {
1086
1322
 
1087
1323
  func windowBounds(for target: TargetApp) -> CGRect? {
1088
1324
  switch target {
1089
- case .simulator:
1090
- return simulatorWindowBounds()
1325
+ case .simulator(let udid):
1326
+ return simulatorWindowBounds(udid: udid)
1091
1327
  case .macApp(let pid, _, _):
1092
1328
  guard let windowInfo = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
1093
1329
  as? [[String: Any]] else {
@@ -1119,7 +1355,7 @@ func windowBounds(for target: TargetApp) -> CGRect? {
1119
1355
  }
1120
1356
  }
1121
1357
 
1122
- func simulatorWindowBounds() -> CGRect? {
1358
+ func simulatorWindowBounds(udid: String? = nil) -> CGRect? {
1123
1359
  guard let windowInfo = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
1124
1360
  as? [[String: Any]] else {
1125
1361
  return nil
@@ -1131,9 +1367,21 @@ func simulatorWindowBounds() -> CGRect? {
1131
1367
  return owner == "Simulator" && (layer ?? 0) == 0
1132
1368
  }
1133
1369
 
1370
+ let normalizedDeviceName = simulatorDeviceName(for: udid).map(normalizeText)
1371
+ let preferredWindows: [[String: Any]]
1372
+ if let normalizedDeviceName {
1373
+ let matched = windows.filter { info in
1374
+ let title = (info[kCGWindowName as String] as? String) ?? ""
1375
+ return normalizeText(title).contains(normalizedDeviceName)
1376
+ }
1377
+ preferredWindows = matched.isEmpty ? windows : matched
1378
+ } else {
1379
+ preferredWindows = windows
1380
+ }
1381
+
1134
1382
  var best: CGRect?
1135
1383
  var bestArea: CGFloat = 0
1136
- for info in windows {
1384
+ for info in preferredWindows {
1137
1385
  guard let boundsDict = info[kCGWindowBounds as String] as? [String: Any],
1138
1386
  let x = boundsDict["X"] as? CGFloat,
1139
1387
  let y = boundsDict["Y"] as? CGFloat,
@@ -1153,8 +1401,8 @@ func simulatorWindowBounds() -> CGRect? {
1153
1401
 
1154
1402
  func pointInWindow(x: Double, y: Double, for target: TargetApp) throws -> CGPoint {
1155
1403
  switch target {
1156
- case .simulator:
1157
- return try pointInSimulatorWindow(x: x, y: y)
1404
+ case .simulator(let udid):
1405
+ return try pointInSimulatorWindow(x: x, y: y, udid: udid)
1158
1406
  case .macApp:
1159
1407
  guard let bounds = windowBounds(for: target) else {
1160
1408
  throw NativeError.commandFailed("Application window not found. Ensure the app is running and visible.")
@@ -1165,8 +1413,8 @@ func pointInWindow(x: Double, y: Double, for target: TargetApp) throws -> CGPoin
1165
1413
  }
1166
1414
  }
1167
1415
 
1168
- func pointInSimulatorWindow(x: Double, y: Double) throws -> CGPoint {
1169
- guard let bounds = simulatorWindowBounds() else {
1416
+ func pointInSimulatorWindow(x: Double, y: Double, udid: String? = nil) throws -> CGPoint {
1417
+ guard let bounds = simulatorWindowBounds(udid: udid) else {
1170
1418
  throw NativeError.commandFailed("Simulator window not found. Ensure Simulator is running and visible.")
1171
1419
  }
1172
1420
  let targetX = bounds.origin.x + CGFloat(x)
@@ -1176,19 +1424,19 @@ func pointInSimulatorWindow(x: Double, y: Double) throws -> CGPoint {
1176
1424
 
1177
1425
  // MARK: - Simulator Content Bounds
1178
1426
 
1179
- func simulatorContentBounds() -> CGRect? {
1427
+ func simulatorContentBounds(udid: String? = nil) -> CGRect? {
1180
1428
  guard let appRoot = try? simulatorAccessibilityRootElement() else {
1181
- return simulatorWindowBounds()
1429
+ return simulatorWindowBounds(udid: udid)
1182
1430
  }
1183
- if let contentGroup = simulatorContentRootElement(from: appRoot),
1431
+ if let contentGroup = simulatorContentRootElement(from: appRoot, udid: udid),
1184
1432
  let frame = FrameAttribute(contentGroup) {
1185
1433
  return frame
1186
1434
  }
1187
- return simulatorWindowBounds()
1435
+ return simulatorWindowBounds(udid: udid)
1188
1436
  }
1189
1437
 
1190
- func pointInSimulatorContent(x: Double, y: Double) throws -> CGPoint {
1191
- guard let bounds = simulatorContentBounds() else {
1438
+ func pointInSimulatorContent(x: Double, y: Double, udid: String? = nil) throws -> CGPoint {
1439
+ guard let bounds = simulatorContentBounds(udid: udid) else {
1192
1440
  throw NativeError.commandFailed("Simulator content area not found. Ensure Simulator is running and visible.")
1193
1441
  }
1194
1442
  let targetX = bounds.origin.x + CGFloat(x)
@@ -1198,18 +1446,18 @@ func pointInSimulatorContent(x: Double, y: Double) throws -> CGPoint {
1198
1446
 
1199
1447
  func pointForInput(x: Double, y: Double, for target: TargetApp) throws -> CGPoint {
1200
1448
  switch target {
1201
- case .simulator:
1202
- return try pointInSimulatorContent(x: x, y: y)
1449
+ case .simulator(let udid):
1450
+ return try pointInSimulatorContent(x: x, y: y, udid: udid)
1203
1451
  case .macApp:
1204
1452
  return try pointInWindow(x: x, y: y, for: target)
1205
1453
  }
1206
1454
  }
1207
1455
 
1208
- func simulatorScrollAnchorPoint(x: Double?, y: Double?) throws -> CGPoint {
1456
+ func simulatorScrollAnchorPoint(x: Double?, y: Double?, udid: String? = nil) throws -> CGPoint {
1209
1457
  if let x, let y {
1210
1458
  return CGPoint(x: x, y: y)
1211
1459
  }
1212
- guard let bounds = simulatorContentBounds() else {
1460
+ guard let bounds = simulatorContentBounds(udid: udid) else {
1213
1461
  throw NativeError.commandFailed("Simulator content area not found. Ensure Simulator is running and visible.")
1214
1462
  }
1215
1463
  return CGPoint(x: bounds.width * 0.5, y: bounds.height * 0.5)
@@ -1 +1 @@
1
- let BAEPSAE_VERSION = "6.1.1"
1
+ let BAEPSAE_VERSION = "6.2.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-baepsae",
3
- "version": "6.1.1",
3
+ "version": "6.2.0",
4
4
  "mcpName": "io.github.oozoofrog/baepsae",
5
5
  "description": "Local MCP server for iOS Simulator and macOS app automation with a native Swift bridge",
6
6
  "author": "oozoofrog",
@@ -29,6 +29,11 @@
29
29
  "test:drift": "npm run build && node scripts/generate-tool-manifest.mjs --check && node --test tests/tool.manifest.test.mjs",
30
30
  "test:real": "npm run build && node --test tests/mcp.real.test.mjs",
31
31
  "test:real:preflight": "npm run build && node --test --test-name-pattern=\"Preflight diagnostics\" tests/mcp.real.test.mjs",
32
+ "test:real:media": "npm run build && node scripts/verify-media-capture.mjs",
33
+ "research:tap-tab": "npm run build && node scripts/research-tap-tab-grid.mjs",
34
+ "research:tabbar-actions": "node scripts/dump-tabbar-actions.mjs",
35
+ "research:coordinate-calibration": "node scripts/research-coordinate-calibration.mjs",
36
+ "research:input-channels": "node scripts/research-input-channels.mjs",
32
37
  "test:real:sim": "npm run build && node --test --test-name-pattern=\"^(Preflight diagnostics|Setup:|Phase 1:|Phase 2:|Phase 2b:|Phase 2c:|Phase 3:|cleanup:)\" tests/mcp.real.test.mjs",
33
38
  "test:real:mac": "npm run build && node --test --test-name-pattern=\"^Phase 4:\" tests/mcp.real.test.mjs",
34
39
  "bundle:native": "bash scripts/bundle-native.sh",