react-native-mcp-kit 2.1.0 → 2.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.
Files changed (92) hide show
  1. package/README.md +20 -11
  2. package/dist/bin/ios-hid +0 -0
  3. package/dist/client/contexts/McpContext/McpProvider.d.ts.map +1 -1
  4. package/dist/client/contexts/McpContext/McpProvider.js +6 -3
  5. package/dist/client/contexts/McpContext/McpProvider.js.map +1 -1
  6. package/dist/modules/alert/alert.d.ts.map +1 -1
  7. package/dist/modules/alert/alert.js +6 -5
  8. package/dist/modules/alert/alert.js.map +1 -1
  9. package/dist/modules/console/console.d.ts.map +1 -1
  10. package/dist/modules/console/console.js +18 -13
  11. package/dist/modules/console/console.js.map +1 -1
  12. package/dist/modules/device/device.d.ts.map +1 -1
  13. package/dist/modules/device/device.js +23 -27
  14. package/dist/modules/device/device.js.map +1 -1
  15. package/dist/modules/errors/errors.d.ts.map +1 -1
  16. package/dist/modules/errors/errors.js +92 -11
  17. package/dist/modules/errors/errors.js.map +1 -1
  18. package/dist/modules/errors/types.d.ts +8 -0
  19. package/dist/modules/errors/types.d.ts.map +1 -1
  20. package/dist/modules/fiberTree/fiberTree.d.ts +4 -0
  21. package/dist/modules/fiberTree/fiberTree.d.ts.map +1 -1
  22. package/dist/modules/fiberTree/fiberTree.js +353 -114
  23. package/dist/modules/fiberTree/fiberTree.js.map +1 -1
  24. package/dist/modules/fiberTree/index.d.ts +1 -0
  25. package/dist/modules/fiberTree/index.d.ts.map +1 -1
  26. package/dist/modules/fiberTree/index.js +14 -1
  27. package/dist/modules/fiberTree/index.js.map +1 -1
  28. package/dist/modules/fiberTree/types.d.ts +40 -0
  29. package/dist/modules/fiberTree/types.d.ts.map +1 -1
  30. package/dist/modules/fiberTree/utils.d.ts +13 -0
  31. package/dist/modules/fiberTree/utils.d.ts.map +1 -1
  32. package/dist/modules/fiberTree/utils.js +195 -23
  33. package/dist/modules/fiberTree/utils.js.map +1 -1
  34. package/dist/modules/i18next/i18next.d.ts.map +1 -1
  35. package/dist/modules/i18next/i18next.js +30 -16
  36. package/dist/modules/i18next/i18next.js.map +1 -1
  37. package/dist/modules/index.d.ts +1 -0
  38. package/dist/modules/index.d.ts.map +1 -1
  39. package/dist/modules/index.js +3 -1
  40. package/dist/modules/index.js.map +1 -1
  41. package/dist/modules/logBox/index.d.ts +2 -0
  42. package/dist/modules/logBox/index.d.ts.map +1 -0
  43. package/dist/modules/logBox/index.js +6 -0
  44. package/dist/modules/logBox/index.js.map +1 -0
  45. package/dist/modules/logBox/logBox.d.ts +3 -0
  46. package/dist/modules/logBox/logBox.d.ts.map +1 -0
  47. package/dist/modules/logBox/logBox.js +234 -0
  48. package/dist/modules/logBox/logBox.js.map +1 -0
  49. package/dist/modules/navigation/navigation.d.ts.map +1 -1
  50. package/dist/modules/navigation/navigation.js +130 -42
  51. package/dist/modules/navigation/navigation.js.map +1 -1
  52. package/dist/modules/network/network.d.ts.map +1 -1
  53. package/dist/modules/network/network.js +262 -67
  54. package/dist/modules/network/network.js.map +1 -1
  55. package/dist/modules/network/types.d.ts +35 -3
  56. package/dist/modules/network/types.d.ts.map +1 -1
  57. package/dist/modules/reactQuery/reactQuery.d.ts.map +1 -1
  58. package/dist/modules/reactQuery/reactQuery.js +25 -15
  59. package/dist/modules/reactQuery/reactQuery.js.map +1 -1
  60. package/dist/modules/storage/storage.d.ts.map +1 -1
  61. package/dist/modules/storage/storage.js +23 -16
  62. package/dist/modules/storage/storage.js.map +1 -1
  63. package/dist/server/host/hostModule.d.ts.map +1 -1
  64. package/dist/server/host/hostModule.js +19 -1
  65. package/dist/server/host/hostModule.js.map +1 -1
  66. package/dist/server/host/tools/capture.d.ts.map +1 -1
  67. package/dist/server/host/tools/capture.js +111 -28
  68. package/dist/server/host/tools/capture.js.map +1 -1
  69. package/dist/server/host/tools/devices.js +1 -1
  70. package/dist/server/host/tools/devices.js.map +1 -1
  71. package/dist/server/host/tools/input.d.ts +3 -0
  72. package/dist/server/host/tools/input.d.ts.map +1 -1
  73. package/dist/server/host/tools/input.js +197 -5
  74. package/dist/server/host/tools/input.js.map +1 -1
  75. package/dist/server/host/tools/lifecycle.d.ts.map +1 -1
  76. package/dist/server/host/tools/lifecycle.js +5 -4
  77. package/dist/server/host/tools/lifecycle.js.map +1 -1
  78. package/dist/server/host/tools/symbolicate.d.ts +3 -0
  79. package/dist/server/host/tools/symbolicate.d.ts.map +1 -0
  80. package/dist/server/host/tools/symbolicate.js +199 -0
  81. package/dist/server/host/tools/symbolicate.js.map +1 -0
  82. package/dist/server/host/tools/tapFiber.d.ts +3 -0
  83. package/dist/server/host/tools/tapFiber.d.ts.map +1 -0
  84. package/dist/server/host/tools/tapFiber.js +89 -0
  85. package/dist/server/host/tools/tapFiber.js.map +1 -0
  86. package/dist/server/host/types.d.ts +14 -0
  87. package/dist/server/host/types.d.ts.map +1 -1
  88. package/dist/server/mcpServer.d.ts +6 -0
  89. package/dist/server/mcpServer.d.ts.map +1 -1
  90. package/dist/server/mcpServer.js +440 -93
  91. package/dist/server/mcpServer.js.map +1 -1
  92. package/package.json +1 -1
@@ -12,25 +12,152 @@ Multiple React Native apps can connect simultaneously — each is identified by
12
12
  ## How to interact
13
13
 
14
14
  1. Use \`connection_status\` to check which clients are connected.
15
- 2. Use \`list_tools\` to browse all available tool names and short descriptions. The response is compact — modules that are structurally identical across multiple clients are deduplicated into a single entry with a \`clientIds\` array, and input schemas are omitted.
15
+ 2. Use \`list_tools\` to browse all available tool names and short descriptions. The response is compact — modules that are structurally identical across multiple clients are deduplicated into a single entry with a \`clientIds\` array, and input schemas are omitted. Narrow the listing with \`{ module }\` or \`{ clientId }\`, or pass \`{ compact: true }\` to drop module-level descriptions.
16
16
  3. Use \`describe_tool\` with \`{ tool, clientId? }\` to fetch the full input schema of a specific tool before calling it. Required when you need to know the argument shape. Host tools are resolved directly (no clientId needed). For in-app tools, omit \`clientId\` to auto-pick; specify it only when multiple clients have the same tool with different schemas.
17
- 4. Use \`call\` to invoke any tool with format: module${protocol_1.MODULE_SEPARATOR}method (e.g. navigation${protocol_1.MODULE_SEPARATOR}navigate). When more than one client is connected, specify \`clientId\`. When exactly one client is connected, \`clientId\` is optional — it's auto-picked.
18
- 5. Use \`state_list\` / \`state_get\` to read app state exposed via useMcpState. State is scoped per client; specify \`clientId\` when multiple clients are connected.
17
+ 4. Use \`call\` to invoke any tool with format: module${protocol_1.MODULE_SEPARATOR}method (e.g. navigation${protocol_1.MODULE_SEPARATOR}navigate). When more than one client is connected, specify \`clientId\`. When exactly one client is connected, \`clientId\` is optional — it's auto-picked. \`args\` accepts either a plain object or a JSON string — prefer objects to avoid quote escaping.
18
+ 5. Use \`wait_until\` to poll any tool until a predicate over its result holds (or timeout). Replaces "screenshot in a loop + sleep" for things like "wait for screen X", "wait for the spinner to disappear", "wait for network to idle". Predicate supports compound forms: { all: [...] } (AND), { any: [...] } (OR), { not: predicate }.
19
+ 6. Use \`assert\` for a single-shot checkpoint after actions — same predicate vocabulary as wait_until, returns { pass, actual, expected?, result? }. Natural pair: do action → wait_until → assert.
20
+ 7. Use \`host${protocol_1.MODULE_SEPARATOR}tap_fiber\` to collapse "fiber_tree__query → host__tap at bounds" into one call. Pass fiber_tree steps; if exactly one fiber matches, its center is tapped. Ambiguous match returns the candidate list so you can add \`index\` or narrow the chain.
21
+ 8. Use \`state_list\` / \`state_get\` to read app state exposed via useMcpState. State is scoped per client; specify \`clientId\` when multiple clients are connected.
19
22
 
20
- Some tools run inline on the MCP server host (e.g. \`host${protocol_1.MODULE_SEPARATOR}screenshot\`, \`host${protocol_1.MODULE_SEPARATOR}list_devices\`, \`host${protocol_1.MODULE_SEPARATOR}launch_app\`, \`host${protocol_1.MODULE_SEPARATOR}terminate_app\`, \`host${protocol_1.MODULE_SEPARATOR}restart_app\`) and work even when no React Native client is connected. They use xcrun simctl / adb on the dev machine. When \`clientId\` is provided, host tools use that client's platform/label/deviceId as hints to resolve the target device; otherwise they prefer the device of the single connected client, falling back to the single booted sim / online device. \`launch_app\`, \`terminate_app\`, and \`restart_app\` accept an \`appId\` arg (iOS bundle ID / Android package name); omit it to reuse the target client's registered \`bundleId\` from its connection metadata.
23
+ Some tools run inline on the MCP server host (e.g. \`host${protocol_1.MODULE_SEPARATOR}screenshot\`, \`host${protocol_1.MODULE_SEPARATOR}list_devices\`, \`host${protocol_1.MODULE_SEPARATOR}launch_app\`, \`host${protocol_1.MODULE_SEPARATOR}terminate_app\`, \`host${protocol_1.MODULE_SEPARATOR}restart_app\`, \`host${protocol_1.MODULE_SEPARATOR}symbolicate\`) and work even when no React Native client is connected. They use xcrun simctl / adb on the dev machine. When \`clientId\` is provided, host tools use that client's platform/label/deviceId as hints to resolve the target device; otherwise they prefer the device of the single connected client, falling back to the single booted sim / online device. \`launch_app\`, \`terminate_app\`, and \`restart_app\` accept an \`appId\` arg (iOS bundle ID / Android package name); omit it to reuse the target client's registered \`bundleId\` from its connection metadata.
21
24
 
22
25
  ## Driving the UI — pick the right tool
23
- 1. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}find_all\` with \`select: ["mcpId", "name", "bounds"]\` + \`host${protocol_1.MODULE_SEPARATOR}tap\` with \`bounds.centerX\`/\`bounds.centerY\`** the default for touch interactions. Exercises the real OS gesture pipeline, so Pressable/TouchableOpacity feedback, gesture responders, and hit-test logic all run. The bounds come back in physical pixels pass them straight to \`host${protocol_1.MODULE_SEPARATOR}tap\`, no scaling. The \`select\` parameter also saves tokens by omitting heavy \`props\` from the response.
24
- 2. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}invoke\`** when you need to bypass the gesture pipeline — e.g. for non-tap callbacks like \`onChangeText\` / \`onValueChange\` / \`onRefresh\`, or when a component is inside a scroll/gesture-handler parent that swallows taps. Calls the prop directly — faster and immune to overlay/occlusion, but does not exercise touch handlers.
25
- 3. **\`host${protocol_1.MODULE_SEPARATOR}screenshot\` + manual coordinate estimation + \`host${protocol_1.MODULE_SEPARATOR}tap\`** ONLY for non-React surfaces: system permission dialogs, native alerts, the on-screen keyboard, WebView content, native splash. These have no fiber and no bounds.
26
+ 1. **\`host${protocol_1.MODULE_SEPARATOR}tap_fiber\` with \`steps: [...]\`** the canonical way to simulate a user tap. One call locates the fiber via fiber_tree__query and taps its center through the real OS gesture pipeline, so Pressable feedback, gesture responders, and hit-test logic all run. Ambiguous matches return a candidate list so you can add \`index\` or narrow \`steps\`. This is what you want whenever the user asks to simulate a tap / press / button click.
27
+ 2. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}query\` with \`select: ["mcpId","name","bounds"]\` + \`host${protocol_1.MODULE_SEPARATOR}tap\`** when you want to inspect a match set before committing — e.g. verify bounds, or skim candidates before picking one. \`props\` is opt-in on \`select\` to keep responses small.
28
+ 3. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}invoke\`** for non-gesture callbacks anything state-driving the user didn't trigger with a finger. Good when the component is off-screen / virtualised, when a scroll-handler parent swallows taps, or when you're specifically testing a callback in isolation without the gesture pipeline. For simulating a user tap, prefer tap_fiber (above).
29
+ 4. **\`host${protocol_1.MODULE_SEPARATOR}screenshot\` + manual coordinate estimation + \`host${protocol_1.MODULE_SEPARATOR}tap\`** ONLY for non-React surfaces: system permission dialogs, native alerts, the on-screen keyboard, WebView content, native splash. These have no fiber and no bounds. Pair with \`region: { x, y, width, height }\` to screenshot just the area you're inspecting — vision-token cheap.
26
30
 
27
- \`host${protocol_1.MODULE_SEPARATOR}tap\` / \`host${protocol_1.MODULE_SEPARATOR}swipe\` / \`host${protocol_1.MODULE_SEPARATOR}type_text\` / \`host${protocol_1.MODULE_SEPARATOR}press_key\` work on both platforms with no external daemons: Android via \`adb shell input\`, iOS via a bundled \`ios-hid\` binary that injects HID events directly into iOS Simulator through SimulatorKit.
31
+ Gesture tools: \`host${protocol_1.MODULE_SEPARATOR}tap\` / \`host${protocol_1.MODULE_SEPARATOR}long_press\` / \`host${protocol_1.MODULE_SEPARATOR}swipe\` / \`host${protocol_1.MODULE_SEPARATOR}drag\` / \`host${protocol_1.MODULE_SEPARATOR}type_text\` / \`host${protocol_1.MODULE_SEPARATOR}type_text_batch\` / \`host${protocol_1.MODULE_SEPARATOR}press_key\` work on both platforms with no external daemons: Android via \`adb shell input\`, iOS via a bundled \`ios-hid\` binary that injects HID events directly into iOS Simulator through SimulatorKit.
32
+
33
+ Stack traces: \`errors${protocol_1.MODULE_SEPARATOR}get_errors\` and \`log_box${protocol_1.MODULE_SEPARATOR}get_logs\` return parsed \`stackFrames\` you can pass straight into \`host${protocol_1.MODULE_SEPARATOR}symbolicate\` to resolve bundled frames back to source paths via Metro.
28
34
  `;
29
35
  const jsonError = (msg) => {
30
36
  return {
31
37
  content: [{ text: JSON.stringify({ error: msg }), type: 'text' }],
32
38
  };
33
39
  };
40
+ /**
41
+ * Drill into a value by dot-path. Arrays accept numeric indices and also
42
+ * respond to `.length` (handy for "wait until list is empty"). Returns
43
+ * undefined when any intermediate segment is missing.
44
+ */
45
+ const resolvePath = (value, path) => {
46
+ if (!path)
47
+ return value;
48
+ let current = value;
49
+ for (const key of path.split('.')) {
50
+ if (current == null)
51
+ return undefined;
52
+ if (Array.isArray(current)) {
53
+ if (key === 'length') {
54
+ current = current.length;
55
+ continue;
56
+ }
57
+ const idx = Number.parseInt(key, 10);
58
+ current = Number.isNaN(idx) ? undefined : current[idx];
59
+ continue;
60
+ }
61
+ if (typeof current === 'object') {
62
+ current = current[key];
63
+ continue;
64
+ }
65
+ return undefined;
66
+ }
67
+ return current;
68
+ };
69
+ const evalLeaf = (actual, op, expected) => {
70
+ switch (op) {
71
+ case 'exists':
72
+ return actual !== undefined && actual !== null;
73
+ case 'notExists':
74
+ return actual === undefined || actual === null;
75
+ case 'equals':
76
+ return Object.is(actual, expected);
77
+ case 'notEquals':
78
+ return !Object.is(actual, expected);
79
+ case 'contains': {
80
+ if (typeof actual === 'string' && typeof expected === 'string') {
81
+ return actual.includes(expected);
82
+ }
83
+ if (Array.isArray(actual))
84
+ return actual.includes(expected);
85
+ return false;
86
+ }
87
+ case 'notContains': {
88
+ if (typeof actual === 'string' && typeof expected === 'string') {
89
+ return !actual.includes(expected);
90
+ }
91
+ if (Array.isArray(actual))
92
+ return !actual.includes(expected);
93
+ return false;
94
+ }
95
+ case 'gt':
96
+ return typeof actual === 'number' && typeof expected === 'number' && actual > expected;
97
+ case 'gte':
98
+ return typeof actual === 'number' && typeof expected === 'number' && actual >= expected;
99
+ case 'lt':
100
+ return typeof actual === 'number' && typeof expected === 'number' && actual < expected;
101
+ case 'lte':
102
+ return typeof actual === 'number' && typeof expected === 'number' && actual <= expected;
103
+ default:
104
+ return false;
105
+ }
106
+ };
107
+ /**
108
+ * Evaluate a predicate (leaf or compound) against a result object. Compound
109
+ * forms short-circuit: all stops on first false, any stops on first true.
110
+ */
111
+ const evalPredicate = (result, predicate) => {
112
+ if ('all' in predicate && Array.isArray(predicate.all)) {
113
+ for (const sub of predicate.all) {
114
+ if (!evalPredicate(result, sub))
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+ if ('any' in predicate && Array.isArray(predicate.any)) {
120
+ for (const sub of predicate.any) {
121
+ if (evalPredicate(result, sub))
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ if ('not' in predicate && predicate.not && typeof predicate.not === 'object') {
127
+ return !evalPredicate(result, predicate.not);
128
+ }
129
+ const leaf = predicate;
130
+ if (typeof leaf.op !== 'string')
131
+ return false;
132
+ return evalLeaf(resolvePath(result, leaf.path), leaf.op, leaf.value);
133
+ };
134
+ /**
135
+ * Parse a `call`-style args argument that may arrive as a JSON string (older
136
+ * clients) or a plain object (new form). Returns { ok, args } or { ok: false,
137
+ * error } on malformed JSON.
138
+ */
139
+ const parseCallArgs = (raw) => {
140
+ if (raw === undefined || raw === null)
141
+ return { args: {}, ok: true };
142
+ if (typeof raw === 'string') {
143
+ if (raw.length === 0)
144
+ return { args: {}, ok: true };
145
+ try {
146
+ const parsed = JSON.parse(raw);
147
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
148
+ return { args: parsed, ok: true };
149
+ }
150
+ return { error: 'Parsed args must be an object.', ok: false };
151
+ }
152
+ catch {
153
+ return { error: 'Invalid JSON in args.', ok: false };
154
+ }
155
+ }
156
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
157
+ return { args: raw, ok: true };
158
+ }
159
+ return { error: 'args must be an object or a JSON string.', ok: false };
160
+ };
34
161
  /**
35
162
  * Recursively serializes a value to JSON with sorted object keys, producing a
36
163
  * stable canonical form that's safe to use as a dedup Map key. Arrays keep
@@ -128,12 +255,12 @@ class McpServerWrapper {
128
255
  openWorldHint: true,
129
256
  title: 'Call Tool',
130
257
  },
131
- description: 'Call a tool registered by a React Native app client. Use list_tools first to see available tools. When multiple clients are connected, specify clientId; otherwise it is auto-picked.',
258
+ description: 'Call a tool registered by a React Native app client. Use list_tools first to see available tools. When multiple clients are connected, specify clientId; otherwise it is auto-picked. `args` accepts either a plain object or a JSON string — objects are preferred to avoid escaping quotes.',
132
259
  inputSchema: {
133
260
  args: zod_1.z
134
- .string()
261
+ .union([zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())])
135
262
  .optional()
136
- .describe('Arguments as JSON string (e.g. {"screen": "AUTH_LOGIN_SCREEN"})'),
263
+ .describe('Tool arguments as a plain object (e.g. { screen: "AUTH_LOGIN_SCREEN" }) or a JSON string.'),
137
264
  clientId: zod_1.z
138
265
  .string()
139
266
  .optional()
@@ -143,102 +270,226 @@ class McpServerWrapper {
143
270
  .describe(`Tool name in format "module${protocol_1.MODULE_SEPARATOR}method" (e.g. "navigation${protocol_1.MODULE_SEPARATOR}navigate")`),
144
271
  },
145
272
  }, async ({ args, clientId, tool }) => {
146
- let parsedArgs = {};
147
- if (args) {
148
- try {
149
- parsedArgs = JSON.parse(args);
150
- }
151
- catch {
152
- return jsonError('Invalid JSON in args');
153
- }
154
- }
155
- // Host dispatch — runs inline on the Node server, may work without any connected client
156
- const hostEntry = this.hostToolMap.get(tool);
157
- if (hostEntry) {
158
- try {
159
- const result = await hostEntry.handler(parsedArgs, {
160
- bridge: this.bridge,
161
- requestedClientId: clientId,
162
- });
163
- return { content: this.formatResult(result) };
273
+ const parsed = parseCallArgs(args);
274
+ if (!parsed.ok)
275
+ return jsonError(parsed.error);
276
+ const dispatch = await this.dispatchTool(tool, parsed.args, clientId);
277
+ if (!dispatch.ok)
278
+ return jsonError(dispatch.error);
279
+ return { content: this.formatResult(dispatch.result) };
280
+ });
281
+ this.mcp.registerTool('wait_until', {
282
+ annotations: {
283
+ openWorldHint: true,
284
+ title: 'Wait Until',
285
+ },
286
+ description: `Poll a tool until its result satisfies a predicate, or timeout.
287
+
288
+ Replaces "screenshot in a loop + sleep" with a declarative check. Typical use:
289
+ • wait for navigation to land on a screen
290
+ wait for a spinner / toast to disappear
291
+ • wait for a fiber_tree.query to return matches (or stop returning them)
292
+ • wait for network.get_pending.length to hit 0
293
+
294
+ PREDICATE
295
+ Leaf form: { op, path?, value? }
296
+ op: equals | notEquals | contains | notContains | exists | notExists | gt | gte | lt | lte
297
+ path drills through objects + array indices; arrays also expose .length.
298
+ Compound forms compose and nest:
299
+ { all: [predicate, ...] } — AND
300
+ { any: [predicate, ...] } — OR
301
+ { not: predicate } — negation
302
+ Example: { all: [{op:"equals", path:"name", value:"CART"}, {op:"gt", path:"items.length", value:0}] }
303
+
304
+ RETURNS
305
+ { ok: true, attempts, elapsedMs, matched? } on success — matched is the path-
306
+ resolved value for leaf predicates, omitted for compound.
307
+ { ok: false, reason, attempts, elapsedMs, lastResult, lastError? } on timeout.`,
308
+ inputSchema: {
309
+ args: zod_1.z
310
+ .union([zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())])
311
+ .optional()
312
+ .describe('Arguments for the polled tool — object or JSON string.'),
313
+ clientId: zod_1.z.string().optional().describe('Target client ID, same semantics as `call`.'),
314
+ intervalMs: zod_1.z
315
+ .number()
316
+ .optional()
317
+ .describe('Delay between poll attempts. Default 300, min 50, max 5000.'),
318
+ predicate: zod_1.z
319
+ .object({})
320
+ .passthrough()
321
+ .describe('Leaf { op, path?, value? } or compound { all|any: [...] } / { not: predicate }. See tool description for ops and composition.'),
322
+ timeoutMs: zod_1.z
323
+ .number()
324
+ .optional()
325
+ .describe('Total wait budget. Default 10000, min 500, max 60000.'),
326
+ tool: zod_1.z
327
+ .string()
328
+ .describe(`Tool name to poll (e.g. "navigation${protocol_1.MODULE_SEPARATOR}get_current_route").`),
329
+ },
330
+ }, async ({ args, clientId, intervalMs, predicate, timeoutMs, tool }) => {
331
+ const parsedArgs = parseCallArgs(args);
332
+ if (!parsedArgs.ok)
333
+ return jsonError(parsedArgs.error);
334
+ const pred = predicate;
335
+ const isLeaf = typeof pred.op === 'string';
336
+ const leafPath = isLeaf ? pred.path : undefined;
337
+ const timeout = Math.max(500, Math.min(60_000, timeoutMs ?? 10_000));
338
+ const interval = Math.max(50, Math.min(5_000, intervalMs ?? 300));
339
+ const started = Date.now();
340
+ let attempts = 0;
341
+ let lastResult;
342
+ let lastError;
343
+ while (Date.now() - started < timeout) {
344
+ attempts += 1;
345
+ const dispatch = await this.dispatchTool(tool, parsedArgs.args, clientId);
346
+ if (dispatch.ok) {
347
+ lastResult = dispatch.result;
348
+ if (evalPredicate(lastResult, pred)) {
349
+ const payload = {
350
+ attempts,
351
+ elapsedMs: Date.now() - started,
352
+ ok: true,
353
+ };
354
+ if (isLeaf) {
355
+ payload.matched = resolvePath(lastResult, leafPath);
356
+ }
357
+ return {
358
+ content: [{ text: JSON.stringify(payload, null, 2), type: 'text' }],
359
+ };
360
+ }
164
361
  }
165
- catch (err) {
166
- return jsonError(`Host tool "${tool}" threw: ${err.message}`);
362
+ else {
363
+ lastError = dispatch.error;
167
364
  }
168
- }
169
- const resolution = this.bridge.resolveClient(clientId);
170
- if (!resolution.ok) {
171
- return jsonError(resolution.error);
172
- }
173
- const client = resolution.client;
174
- // Find the module by matching prefix in this client's modules
175
- let mod;
176
- let moduleName = '';
177
- let methodName = '';
178
- for (const m of client.modules) {
179
- const prefix = `${m.name}${protocol_1.MODULE_SEPARATOR}`;
180
- if (tool.startsWith(prefix)) {
181
- mod = m;
182
- moduleName = m.name;
183
- methodName = tool.slice(prefix.length);
365
+ const remaining = timeout - (Date.now() - started);
366
+ if (remaining <= 0)
184
367
  break;
185
- }
368
+ await new Promise((r) => {
369
+ return setTimeout(r, Math.min(interval, remaining));
370
+ });
186
371
  }
187
- // If no module matched, check for dynamic tool prefix or generic module__method
188
- if (!mod) {
189
- if (tool.startsWith(protocol_1.DYNAMIC_PREFIX)) {
190
- moduleName = `${protocol_1.MODULE_SEPARATOR}dynamic`;
191
- methodName = tool.slice(protocol_1.DYNAMIC_PREFIX.length);
192
- }
193
- else {
194
- const idx = tool.indexOf(protocol_1.MODULE_SEPARATOR);
195
- if (idx <= 0) {
196
- return jsonError(`Invalid tool name "${tool}". Use "module${protocol_1.MODULE_SEPARATOR}method" format.`);
197
- }
198
- moduleName = tool.slice(0, idx);
199
- methodName = tool.slice(idx + protocol_1.MODULE_SEPARATOR.length);
200
- }
201
- // Try dispatching via bridge — might be a dynamic tool registered on this client
202
- try {
203
- const result = await this.bridge.call(client.id, moduleName, methodName, parsedArgs);
204
- return { content: this.formatResult(result) };
205
- }
206
- catch {
207
- const allModules = client.modules
208
- .map((m) => {
209
- return m.name;
210
- })
211
- .join(', ');
212
- const dynNames = [...client.dynamicTools.keys()].join(', ');
213
- return jsonError(`Tool "${tool}" not found on client '${client.id}'. Modules: ${allModules || '(none)'}. Dynamic: ${dynNames || '(none)'}`);
214
- }
372
+ return {
373
+ content: [
374
+ {
375
+ text: JSON.stringify({
376
+ attempts,
377
+ elapsedMs: Date.now() - started,
378
+ lastError,
379
+ lastResult,
380
+ ok: false,
381
+ reason: lastError
382
+ ? `Last dispatch failed: ${lastError}`
383
+ : `Predicate did not hold within ${timeout}ms`,
384
+ }, null, 2),
385
+ type: 'text',
386
+ },
387
+ ],
388
+ };
389
+ });
390
+ this.mcp.registerTool('assert', {
391
+ annotations: {
392
+ openWorldHint: true,
393
+ title: 'Assert',
394
+ },
395
+ description: `Single-shot assertion over a tool's result. Same predicate vocabulary (including { all / any / not }) as wait_until, but one attempt and a standardized diff on failure.
396
+
397
+ Returns { pass: true, actual? } on success — actual is the path-resolved value for leaf predicates, omitted for compound.
398
+ Returns { pass: false, actual, expected?, op?, path?, message?, result } on predicate failure.
399
+ Returns { pass: false, error, message? } when the tool dispatch itself threw.
400
+
401
+ Useful after wait_until as a checkpoint — the pair reads "do action → wait → assert" which produces a clean audit trail in session logs.`,
402
+ inputSchema: {
403
+ args: zod_1.z
404
+ .union([zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())])
405
+ .optional()
406
+ .describe('Arguments for the asserted tool — object or JSON string.'),
407
+ clientId: zod_1.z.string().optional().describe('Target client ID, same semantics as `call`.'),
408
+ message: zod_1.z
409
+ .string()
410
+ .optional()
411
+ .describe('Optional human-readable description of the check; echoed in the failure payload.'),
412
+ predicate: zod_1.z
413
+ .object({})
414
+ .passthrough()
415
+ .describe('Leaf { op, path?, value? } or compound { all|any: [...] } / { not: predicate }. See wait_until for full semantics.'),
416
+ tool: zod_1.z
417
+ .string()
418
+ .describe(`Tool name to call once (e.g. "fiber_tree${protocol_1.MODULE_SEPARATOR}query").`),
419
+ },
420
+ }, async ({ args, clientId, message, predicate, tool }) => {
421
+ const parsedArgs = parseCallArgs(args);
422
+ if (!parsedArgs.ok)
423
+ return jsonError(parsedArgs.error);
424
+ const dispatch = await this.dispatchTool(tool, parsedArgs.args, clientId);
425
+ if (!dispatch.ok) {
426
+ return {
427
+ content: [
428
+ {
429
+ text: JSON.stringify({ error: dispatch.error, message, pass: false }, null, 2),
430
+ type: 'text',
431
+ },
432
+ ],
433
+ };
215
434
  }
216
- const toolDef = mod.tools.find((t) => {
217
- return t.name === methodName;
218
- });
219
- if (!toolDef) {
220
- return jsonError(`Tool "${methodName}" not found in module "${moduleName}" on client '${client.id}'. Available: ${mod.tools
221
- .map((t) => {
222
- return t.name;
223
- })
224
- .join(', ')}`);
435
+ const pred = predicate;
436
+ const isLeaf = typeof pred.op === 'string';
437
+ const leafPath = isLeaf ? pred.path : undefined;
438
+ const leafValue = isLeaf ? pred.value : undefined;
439
+ const leafOp = isLeaf ? pred.op : undefined;
440
+ const pass = evalPredicate(dispatch.result, pred);
441
+ const payload = { pass };
442
+ if (isLeaf)
443
+ payload.actual = resolvePath(dispatch.result, leafPath);
444
+ if (!pass) {
445
+ if (isLeaf) {
446
+ payload.expected = leafValue;
447
+ payload.op = leafOp;
448
+ if (leafPath)
449
+ payload.path = leafPath;
450
+ }
451
+ if (message)
452
+ payload.message = message;
453
+ payload.result = dispatch.result;
225
454
  }
226
- const result = await this.bridge.call(client.id, moduleName, methodName, parsedArgs, toolDef.timeout);
227
- return { content: this.formatResult(result) };
455
+ return {
456
+ content: [{ text: JSON.stringify(payload, null, 2), type: 'text' }],
457
+ };
228
458
  });
229
459
  this.mcp.registerTool('list_tools', {
230
460
  annotations: {
231
461
  readOnlyHint: true,
232
462
  title: 'List Tools',
233
463
  },
234
- description: 'Browse available tools with compact (schema-free) descriptions. Modules with identical shape across multiple clients are deduplicated into a single entry with a clientIds array. Use describe_tool to fetch the full input schema for a specific tool before calling it.',
235
- }, async () => {
236
- const clients = this.bridge.listClients();
464
+ description: 'Browse available tools with compact (schema-free) descriptions. Modules with identical shape across multiple clients are deduplicated into a single entry with a clientIds array. Use describe_tool to fetch the full input schema for a specific tool before calling it. Pass `module` to narrow to one module, `clientId` to narrow to one client, `compact: true` to drop long module-level descriptions.',
465
+ inputSchema: {
466
+ clientId: zod_1.z
467
+ .string()
468
+ .optional()
469
+ .describe('Narrow listing to a single client. Omit for all connected clients.'),
470
+ compact: zod_1.z
471
+ .boolean()
472
+ .optional()
473
+ .describe('Drop module-level descriptions (still keeps per-tool one-liners). Default false.'),
474
+ module: zod_1.z
475
+ .string()
476
+ .optional()
477
+ .describe('Narrow listing to a single module name (e.g. "fiber_tree", "host"). Omit for all.'),
478
+ },
479
+ }, async ({ clientId, compact, module }) => {
480
+ const allClients = this.bridge.listClients();
481
+ const clients = clientId
482
+ ? allClients.filter((c) => {
483
+ return c.id === clientId;
484
+ })
485
+ : allClients;
237
486
  // Dedup tool groups across clients by canonical shape
238
487
  const dedupMap = new Map();
239
488
  for (const client of clients) {
240
489
  const groups = this.buildToolGroups(client);
241
490
  for (const group of groups) {
491
+ if (module && group.module !== module)
492
+ continue;
242
493
  const key = canonicalizeGroup(group);
243
494
  const existing = dedupMap.get(key);
244
495
  if (existing) {
@@ -252,7 +503,7 @@ class McpServerWrapper {
252
503
  const modulesPayload = [...dedupMap.values()].map(({ clientIds, group }) => {
253
504
  return {
254
505
  clientIds,
255
- description: group.description,
506
+ description: compact ? undefined : group.description,
256
507
  name: group.module,
257
508
  tools: group.tools.map((t) => {
258
509
  return {
@@ -262,9 +513,13 @@ class McpServerWrapper {
262
513
  }),
263
514
  };
264
515
  });
265
- const hostToolsPayload = this.hostModules.map((mod) => {
516
+ const hostToolsPayload = this.hostModules
517
+ .filter((mod) => {
518
+ return !module || mod.name === module;
519
+ })
520
+ .map((mod) => {
266
521
  return {
267
- description: mod.description,
522
+ description: compact ? undefined : mod.description,
268
523
  name: mod.name,
269
524
  tools: Object.entries(mod.tools).map(([toolName, tool]) => {
270
525
  return {
@@ -536,6 +791,98 @@ class McpServerWrapper {
536
791
  return jsonError(`Tool '${tool}' exists on multiple clients with different schemas: ${candidates}. Specify clientId.`);
537
792
  });
538
793
  }
794
+ /**
795
+ * Execute a single tool by full name, returning the raw handler result.
796
+ * Used by both the `call` tool and meta-tools like `wait_until` that need to
797
+ * invoke other tools without going through the full MCP content wrapping.
798
+ */
799
+ async dispatchTool(tool, args, clientId) {
800
+ const hostEntry = this.hostToolMap.get(tool);
801
+ if (hostEntry) {
802
+ try {
803
+ const result = await hostEntry.handler(args, {
804
+ bridge: this.bridge,
805
+ dispatch: (nextTool, nextArgs, nextClientId) => {
806
+ return this.dispatchTool(nextTool, nextArgs, nextClientId ?? clientId);
807
+ },
808
+ requestedClientId: clientId,
809
+ });
810
+ return { ok: true, result };
811
+ }
812
+ catch (err) {
813
+ return { error: `Host tool "${tool}" threw: ${err.message}`, ok: false };
814
+ }
815
+ }
816
+ const resolution = this.bridge.resolveClient(clientId);
817
+ if (!resolution.ok)
818
+ return { error: resolution.error, ok: false };
819
+ const client = resolution.client;
820
+ let mod;
821
+ let moduleName = '';
822
+ let methodName = '';
823
+ for (const m of client.modules) {
824
+ const prefix = `${m.name}${protocol_1.MODULE_SEPARATOR}`;
825
+ if (tool.startsWith(prefix)) {
826
+ mod = m;
827
+ moduleName = m.name;
828
+ methodName = tool.slice(prefix.length);
829
+ break;
830
+ }
831
+ }
832
+ if (!mod) {
833
+ if (tool.startsWith(protocol_1.DYNAMIC_PREFIX)) {
834
+ moduleName = `${protocol_1.MODULE_SEPARATOR}dynamic`;
835
+ methodName = tool.slice(protocol_1.DYNAMIC_PREFIX.length);
836
+ }
837
+ else {
838
+ const idx = tool.indexOf(protocol_1.MODULE_SEPARATOR);
839
+ if (idx <= 0) {
840
+ return {
841
+ error: `Invalid tool name "${tool}". Use "module${protocol_1.MODULE_SEPARATOR}method" format.`,
842
+ ok: false,
843
+ };
844
+ }
845
+ moduleName = tool.slice(0, idx);
846
+ methodName = tool.slice(idx + protocol_1.MODULE_SEPARATOR.length);
847
+ }
848
+ try {
849
+ const result = await this.bridge.call(client.id, moduleName, methodName, args);
850
+ return { ok: true, result };
851
+ }
852
+ catch {
853
+ const allModules = client.modules
854
+ .map((m) => {
855
+ return m.name;
856
+ })
857
+ .join(', ');
858
+ const dynNames = [...client.dynamicTools.keys()].join(', ');
859
+ return {
860
+ error: `Tool "${tool}" not found on client '${client.id}'. Modules: ${allModules || '(none)'}. Dynamic: ${dynNames || '(none)'}`,
861
+ ok: false,
862
+ };
863
+ }
864
+ }
865
+ const toolDef = mod.tools.find((t) => {
866
+ return t.name === methodName;
867
+ });
868
+ if (!toolDef) {
869
+ return {
870
+ error: `Tool "${methodName}" not found in module "${moduleName}" on client '${client.id}'. Available: ${mod.tools
871
+ .map((t) => {
872
+ return t.name;
873
+ })
874
+ .join(', ')}`,
875
+ ok: false,
876
+ };
877
+ }
878
+ try {
879
+ const result = await this.bridge.call(client.id, moduleName, methodName, args, toolDef.timeout);
880
+ return { ok: true, result };
881
+ }
882
+ catch (err) {
883
+ return { error: err.message, ok: false };
884
+ }
885
+ }
539
886
  buildToolGroups(client) {
540
887
  const groups = client.modules.map((mod) => {
541
888
  return {