mobridge-mcp 0.1.0 → 0.3.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.
@@ -57,28 +57,46 @@
57
57
  "note": "iOS has no global BACK/RECENTS/NOTIFICATION_SHADE; BACK is a semantic UI action (find nav button + act)."
58
58
  },
59
59
  "mobile.capability.invoke": {
60
- "level": "PARTIAL",
61
- "allowedOperations": [
62
- "APP",
63
- "CLIPBOARD"
64
- ],
65
- "reasonCode": "NO_NOTIFICATION_LISTENER",
66
- "note": "APP ok; CLIPBOARD limited; no general notification listener."
60
+ "level": "NONE",
61
+ "reasonCode": "NO_BRIDGE_METHOD",
62
+ "note": "The Runner serves no capability.invoke and nothing composes it host-side; every call answered ACTION_UNSUPPORTED. Advertised PARTIAL[APP,CLIPBOARD] until a live tool sweep called it."
63
+ },
64
+ "mobile.screen.watch": {
65
+ "level": "NONE",
66
+ "reasonCode": "NO_EVENT_STREAM_ON_IOS",
67
+ "note": "watch blocks on the Android companion's accessibility event stream; XCUITest exposes no equivalent. Use screen.wait (polling) on iOS."
68
+ },
69
+ "mobile.screen.analyze": {
70
+ "level": "NONE",
71
+ "reasonCode": "DEPENDS_ON_SCREEN_OBSERVE",
72
+ "note": "analyze is host-side but reads screen.observe, which the Runner does not serve."
73
+ },
74
+ "mobile.screen.record": {
75
+ "level": "NONE",
76
+ "reasonCode": "ANDROID_ONLY",
77
+ "note": "Host-side adb screenrecord. iOS recording would be a Runner/simctl path that does not exist."
67
78
  },
68
79
  "mobile.batch": {
69
- "level": "FULL"
80
+ "level": "NONE",
81
+ "reasonCode": "NOT_IMPLEMENTED_ON_IOS",
82
+ "note": "The Runner serves no batch method and nothing composes it host-side. Advertised FULL until the I10 parity gate proved otherwise."
70
83
  },
71
84
  "mobile.gesture.perform": {
72
85
  "level": "FULL"
73
86
  },
74
87
  "mobile.screen.scrollTo": {
75
- "level": "FULL"
88
+ "level": "PARTIAL",
89
+ "reasonCode": "BOUNDED_SWIPE_SEARCH",
90
+ "note": "Host-side bounded swipe search; the query is the only stop condition. No native scroll-to-element on iOS."
76
91
  },
77
92
  "mobile.screen.capture": {
78
- "level": "FULL"
93
+ "level": "NONE",
94
+ "reasonCode": "NOT_IMPLEMENTED_ON_IOS",
95
+ "note": "The Runner serves no screenshot method yet."
79
96
  },
80
97
  "mobile.input.text": {
81
- "level": "FULL"
98
+ "level": "FULL",
99
+ "note": "Host-side: routed to the Runner's screen.act SET_TEXT (same re-resolution and evidence)."
82
100
  },
83
101
  "mobile.workflow.run": {
84
102
  "level": "FULL"
@@ -98,26 +116,28 @@
98
116
  "replacesAndroidTool": "mobile.diag.batterystats"
99
117
  },
100
118
  "runtime.setup": {
101
- "level": "PARTIAL",
102
- "reasonCode": "OPEN_AND_VERIFY_ARE_ADVISORY_ON_IOS",
103
- "allowedOperations": [
104
- "STATUS",
105
- "OPEN",
106
- "VERIFY"
107
- ]
119
+ "level": "NONE",
120
+ "reasonCode": "ANDROID_COMPANION_ONLY",
121
+ "note": "setup.status/open/verify are the Android companion's own permission centre. The Runner serves none of them; iOS setup is device.connect's provisioning path."
108
122
  },
109
123
  "mobile.app.control": {
110
124
  "level": "FULL"
125
+ },
126
+ "mobile.screen.target": {
127
+ "level": "FULL",
128
+ "note": "Resolved host-side in TS (the Runner has no screen.target): ancestor-chain collapse when actionable:true."
111
129
  }
112
130
  },
113
131
  "physical": {
114
132
  "mobile.device.state": {
115
133
  "level": "PARTIAL",
116
- "reasonCode": "ORIENTATION_AND_APPSTATE_ONLY"
134
+ "reasonCode": "ORIENTATION_AND_APPSTATE_ONLY",
135
+ "note": "Host-side from the snapshot; battery/network/locale/storage are not available to a UI-test process."
117
136
  },
118
137
  "mobile.app.list": {
119
138
  "level": "PARTIAL",
120
- "reasonCode": "DEVICECTL_OR_MDM"
139
+ "reasonCode": "DEVICECTL_OR_MDM",
140
+ "note": "Host-side via devicectl/simctl; bundle ids only."
121
141
  },
122
142
  "mobile.app.forceStop": {
123
143
  "level": "NONE",
@@ -164,9 +184,11 @@
164
184
  },
165
185
  "mobile.screen.inspect": {
166
186
  "level": "PARTIAL",
167
- "reasonCode": "DELTA_ONLY_ON_IOS",
187
+ "reasonCode": "NO_VISIBLE_OR_CHILDREN_ON_IOS",
168
188
  "allowedOperations": [
169
- "DELTA"
189
+ "DELTA",
190
+ "TARGET",
191
+ "APP"
170
192
  ]
171
193
  }
172
194
  },
@@ -230,11 +252,13 @@
230
252
  },
231
253
  "mobile.screen.inspect": {
232
254
  "level": "PARTIAL",
233
- "reasonCode": "DELTA_ONLY_ON_IOS",
255
+ "reasonCode": "NO_VISIBLE_OR_CHILDREN_ON_IOS",
234
256
  "allowedOperations": [
235
- "DELTA"
257
+ "DELTA",
258
+ "TARGET",
259
+ "APP"
236
260
  ]
237
261
  }
238
262
  }
239
263
  }
240
- }
264
+ }
@@ -39,7 +39,8 @@
39
39
  "description": "Whether the after-snapshot was captured on a settled screen."
40
40
  },
41
41
  "actionMethod": {
42
- "enum": ["ACCESSIBILITY_ACTION", "ACCESSIBILITY_ACTION_ANCESTOR", "GESTURE_FALLBACK"]
42
+ "enum": ["ACCESSIBILITY_ACTION", "ACCESSIBILITY_ACTION_ANCESTOR", "GESTURE_FALLBACK", "GLOBAL_ACTION"],
43
+ "description": "GLOBAL_ACTION = a device-level navigation key (Android performGlobalAction, iOS XCUIDevice press) — no node is targeted."
43
44
  },
44
45
 
45
46
  "actionRequest": {
@@ -87,6 +88,11 @@
87
88
  "generation": {
88
89
  "type": ["integer", "null"],
89
90
  "description": "Capture generation the target ref was built against (from screen.find/target). When set, the runtime hard-checks it and returns STALE_NODE_REF if the screen advanced — it never re-resolves a stale ref to a similar node."
91
+ },
92
+ "diffDetail": {
93
+ "enum": ["SUMMARY", "FULL"],
94
+ "default": "SUMMARY",
95
+ "description": "How much of the semantic delta observation.diff carries. SUMMARY caps each bucket at 8 entries and adds counts + truncated; FULL returns every node delta. A screen transition diffs hundreds of nodes and the caller rarely needs them all — observation.diffRef re-fetches the exact pair via mobile.screen.diff."
90
96
  }
91
97
  },
92
98
  "allOf": [
@@ -129,7 +135,16 @@
129
135
  "label": { "type": ["string", "null"] }
130
136
  }
131
137
  },
132
- "mutationClass": {
138
+ "appInstallCheck": {
139
+ "enum": ["INSTALLED", "NOT_INSTALLED", "UNKNOWN_TIMEOUT", null],
140
+ "description": "app.open only (iOS): whether the target bundle was confirmed installed before launching. UNKNOWN_TIMEOUT means the inventory did not answer in time — the launch was attempted without asserting installation."
141
+ },
142
+ "warnings": {
143
+ "type": "array",
144
+ "items": { "type": "string" },
145
+ "description": "Non-fatal notices about how this result was produced (e.g. an unverified install state). Never a substitute for an error."
146
+ },
147
+ "mutationClass": {
133
148
  "enum": ["READ_ONLY", "REVERSIBLE", "SIDE_EFFECT", "EXTERNAL_SIDE_EFFECT", "DESTRUCTIVE"],
134
149
  "description": "Advisory, payload-derived classification of how mutating this specific action was. Metadata for the host, not a gate."
135
150
  },
@@ -23,7 +23,7 @@
23
23
  "$defs": {
24
24
  "code": {
25
25
  "enum": [
26
- "DEVICE_OFFLINE", "SESSION_EXPIRED", "PERMISSION_REQUIRED", "WINDOW_UNAVAILABLE",
26
+ "DEVICE_OFFLINE", "SESSION_EXPIRED", "PERMISSION_REQUIRED", "WINDOW_UNAVAILABLE", "DEVICE_LOCKED",
27
27
  "SECURE_WINDOW", "NODE_NOT_FOUND", "AMBIGUOUS_MATCH", "STALE_NODE_REF",
28
28
  "WEAK_REFERENCE_UNRESOLVABLE",
29
29
  "ACTION_UNSUPPORTED", "ACTION_REJECTED", "NO_EFFECT", "VERIFY_TIMEOUT",
@@ -34,6 +34,8 @@
34
34
  "sessionId": { "type": "string" },
35
35
  "capabilities": { "type": "array", "items": { "type": "string" } },
36
36
  "expiresInMinutes": { "type": "integer" },
37
+ "simulated": { "type": "boolean", "description": "TRUE when this session is the built-in fixture backend, not a real device. Nothing done in a simulated session reaches hardware." },
38
+ "warning": { "type": "string", "description": "Human-readable caveat about the session, e.g. that it is simulated and how to reach a real device." },
37
39
  "platform": { "enum": ["ANDROID", "IOS"], "description": "Session platform. Optional for back-compat." },
38
40
  "mode": { "type": ["string", "null"], "description": "iOS driver mode (PHYSICAL|SIMULATOR); null on Android." },
39
41
  "supportedTools": { "type": "array", "items": { "type": "string" }, "description": "Tool names actually advertised this session (platform ∩ profile). Informational; gating is at registration." },
@@ -57,7 +59,8 @@
57
59
  "packageName": { "$ref": "msm:common.schema.json#/$defs/packageName" },
58
60
  "bundleId": { "$ref": "msm:common.schema.json#/$defs/bundleId" },
59
61
  "deepLink": { "type": "string" },
60
- "verify": { "$ref": "msm:wait.schema.json" }
62
+ "verify": { "$ref": "msm:wait.schema.json" },
63
+ "diffDetail": { "enum": ["SUMMARY", "FULL"], "default": "SUMMARY", "description": "How much of the semantic delta observation.diff carries. SUMMARY caps each bucket at 8 entries and adds counts + truncated; FULL returns every node delta. Use observation.diffRef with mobile.screen.diff when the full delta is wanted." }
61
64
  }
62
65
  },
63
66
  "output": {
package/integrity.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "buildId": "38d941d-20260804170901",
3
- "bundleSha256": "0bbc92d88bbe706644d0099c0cdc46bc2b1934674826e07a865f85c3332a8baa"
2
+ "buildId": "56de3b6-20260806163529",
3
+ "bundleSha256": "1a31c3e41873903c2e56233e86fcb5c216c666950bd64f62b7cd603fcb9f85f0"
4
4
  }
package/loader.cjs CHANGED
@@ -14,7 +14,7 @@ const os = require("node:os");
14
14
  const path = require("node:path");
15
15
  const crypto = require("node:crypto");
16
16
 
17
- const BUILD_ID = "38d941d-20260804170901"; // pack-npx stamps the real build id
17
+ const BUILD_ID = "56de3b6-20260806163529"; // pack-npx stamps the real build id
18
18
  const ROOT = __dirname; // package root (loader.cjs sits here)
19
19
  const DIST = path.join(ROOT, "dist");
20
20
  const BUNDLE = path.join(DIST, "main.obf.cjs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobridge-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MoBridge MCP — a stdio MCP server exposing on-device Android/iOS UIs as fast semantic read / observe / find / act / verify tools plus screenshots. Ships an installer that wires it into Claude Code, Claude Desktop, Cursor and Codex.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -63,6 +63,12 @@ final class BridgeServer: XCTestCase {
63
63
 
64
64
  // MARK: dispatch
65
65
 
66
+ /// Every method this Runner serves. Keep in lockstep with the switch in the dispatcher below.
67
+ static let servedMethods = [
68
+ "HELLO", "PING", "SHUTDOWN", "bridge.methods",
69
+ "device.connect", "app.open", "app.terminate", "screen.get", "screen.act", "gesture.perform", "system.action",
70
+ ]
71
+
66
72
  private func handle(_ req: [String: Any]) -> ([String: Any], Bool) {
67
73
  let requestId = req["requestId"] as? Int ?? 0
68
74
  let method = req["method"] as? String ?? ""
@@ -82,12 +88,30 @@ final class BridgeServer: XCTestCase {
82
88
  return ok(["protocolVersion": 1, "runnerInstanceId": runnerInstanceId, "sessionToken": sessionToken, "maxPayloadBytes": 16 * 1024 * 1024, "runnerState": "READY"])
83
89
  case "PING":
84
90
  return ok(["pong": true])
91
+ case "bridge.methods":
92
+ // The Runner's OWN list of served methods — the single source of truth the capability
93
+ // manifest is checked against (I10). Hand-maintaining the manifest is what let it claim
94
+ // FULL for methods that were never implemented; this list cannot drift from the switch
95
+ // below without someone editing it, and the parity gate fails loudly when it does.
96
+ return ok(["methods": Self.servedMethods])
85
97
  case "SHUTDOWN":
86
98
  return ([ "requestId": requestId, "result": ["bye": true] ], true)
87
99
  case "device.connect":
88
100
  return ok(["deviceId": params["deviceId"] as? String ?? "ios-sim", "sessionId": runnerInstanceId, "capabilities": ["screen.get", "screen.find", "screen.act", "screen.wait", "screen.diff"]])
89
101
  case "app.open":
90
102
  return appOpen(params, ok: ok, err: err)
103
+ case "app.terminate":
104
+ // The real terminate for a PHYSICAL device. The host path shelled out to `xcrun simctl
105
+ // terminate`, which is Simulator-only — it could never have worked on hardware. XCUITest
106
+ // owns the app process here, so this is both correct and the only thing available.
107
+ let bid = params["bundleId"] as? String ?? ""
108
+ guard !bid.isEmpty else {
109
+ return err("ACTION_REJECTED", "app.terminate needs a bundleId.", ["Pass the target app's bundle identifier."])
110
+ }
111
+ let target = XCUIApplication(bundleIdentifier: bid)
112
+ let wasRunning = target.state != .notRunning
113
+ target.terminate()
114
+ return ok(["terminated": true, "bundleId": bid, "wasRunning": wasRunning])
91
115
  case "screen.get":
92
116
  return ok(snapshot())
93
117
  case "screen.act":
@@ -170,7 +194,7 @@ final class BridgeServer: XCTestCase {
170
194
  if let snap = try? app.snapshot() {
171
195
  return Xcui.toScreenSnapshot(snap, bundleId: bundle, deviceId: "ios-sim")
172
196
  }
173
- return ["schemaVersion": 1, "platform": "IOS", "nodes": [], "treeHash": "0", "packageName": bundle, "appId": bundle, "snapshotId": "s_empty", "deviceId": "ios-sim", "windowId": 0, "orientation": "portrait", "screenStateId": "ios:0", "compact": true, "timestamp": ISO8601DateFormatter().string(from: Date())]
197
+ return ["schemaVersion": 1, "platform": "IOS", "nodes": [], "treeHash": "0", "packageName": bundle, "appId": bundle, "snapshotId": "s_empty", "deviceId": "ios-sim", "windowId": 0, "orientation": "portrait", "screenStateId": "ios:0", "compact": true, "timestamp": Int(Date().timeIntervalSince1970 * 1000)]
174
198
  }
175
199
 
176
200
  private func appOpen(_ p: [String: Any], ok: ([String: Any]) -> ([String: Any], Bool), err: (String, String, [String]) -> ([String: Any], Bool)) -> ([String: Any], Bool) {
@@ -200,11 +224,19 @@ final class BridgeServer: XCTestCase {
200
224
  private func screenAct(_ p: [String: Any], ok: ([String: Any]) -> ([String: Any], Bool), err: (String, String, [String]) -> ([String: Any], Bool)) -> ([String: Any], Bool) {
201
225
  let before = snapshot()
202
226
  let verb = p["verb"] as? String ?? "CLICK"
227
+ // Narrow the live re-query to the element TYPE TS resolved, not just its label: a table row and
228
+ // the static text inside it share a label, so a label-only query counted 2 and this defence
229
+ // refused every list tap. `nativeType` is this Runner's own "XCUIElementType<raw>" string coming
230
+ // back, so no role mapping is invented here; absent or unparsable, the scope stays .any and the
231
+ // behaviour is exactly what it was.
232
+ let scope: XCUIElement.ElementType = (p["nativeType"] as? String)
233
+ .flatMap { UInt( $0.replacingOccurrences(of: "XCUIElementType", with: "")) }
234
+ .flatMap { XCUIElement.ElementType(rawValue: $0) } ?? .any
203
235
  let query: XCUIElementQuery
204
236
  if let aid = p["automationId"] as? String, !aid.isEmpty {
205
- query = app.descendants(matching: .any).matching(identifier: aid)
237
+ query = app.descendants(matching: scope).matching(identifier: aid)
206
238
  } else if let label = p["label"] as? String, !label.isEmpty {
207
- query = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label))
239
+ query = app.descendants(matching: scope).matching(NSPredicate(format: "label == %@", label))
208
240
  } else {
209
241
  return err("NODE_NOT_FOUND", "screen.act needs automationId or label.", ["Resolve a NodeRef first."])
210
242
  }
@@ -79,7 +79,9 @@ enum Xcui {
79
79
  return [
80
80
  "schemaVersion": 1, "platform": "IOS", "deviceId": deviceId,
81
81
  "snapshotId": "s_\(UUID().uuidString.prefix(8))",
82
- "timestamp": ISO8601DateFormatter().string(from: Date()),
82
+ // Epoch MILLISECONDS, per common.schema.json#/$defs/timestampMs. An ISO8601 string here
83
+ // made every iOS screen.get fail a strict client's output-schema check.
84
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
83
85
  "packageName": bundleId, "appId": bundleId, "windowId": 0, "orientation": "portrait",
84
86
  "screenStateId": "ios:\(th.prefix(4))", "compact": true, "nodes": nodes, "treeHash": th,
85
87
  "truncated": truncated,