react-native-mcp-kit 2.3.1 → 3.0.1

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 (60) hide show
  1. package/README.md +12 -7
  2. package/dist/babel/stripPlugin.d.ts.map +1 -1
  3. package/dist/babel/stripPlugin.js +18 -3
  4. package/dist/babel/stripPlugin.js.map +1 -1
  5. package/dist/babel/testIdPlugin.d.ts.map +1 -1
  6. package/dist/babel/testIdPlugin.js +466 -7
  7. package/dist/babel/testIdPlugin.js.map +1 -1
  8. package/dist/bin/ios-hid +0 -0
  9. package/dist/client/contexts/McpContext/McpProvider.d.ts.map +1 -1
  10. package/dist/client/contexts/McpContext/McpProvider.js +0 -6
  11. package/dist/client/contexts/McpContext/McpProvider.js.map +1 -1
  12. package/dist/client/contexts/McpContext/types.d.ts +0 -2
  13. package/dist/client/contexts/McpContext/types.d.ts.map +1 -1
  14. package/dist/client/core/McpClient.d.ts +0 -2
  15. package/dist/client/core/McpClient.d.ts.map +1 -1
  16. package/dist/client/core/McpClient.js +1 -17
  17. package/dist/client/core/McpClient.js.map +1 -1
  18. package/dist/client/index.d.ts +0 -1
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +1 -3
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/modules/fiberTree/fiberTree.d.ts +17 -0
  27. package/dist/modules/fiberTree/fiberTree.d.ts.map +1 -1
  28. package/dist/modules/fiberTree/fiberTree.js +554 -2
  29. package/dist/modules/fiberTree/fiberTree.js.map +1 -1
  30. package/dist/modules/fiberTree/utils.d.ts +1 -0
  31. package/dist/modules/fiberTree/utils.d.ts.map +1 -1
  32. package/dist/modules/fiberTree/utils.js +7 -6
  33. package/dist/modules/fiberTree/utils.js.map +1 -1
  34. package/dist/server/bridge.d.ts +0 -1
  35. package/dist/server/bridge.d.ts.map +1 -1
  36. package/dist/server/bridge.js +0 -15
  37. package/dist/server/bridge.js.map +1 -1
  38. package/dist/server/canonicalize.d.ts +8 -0
  39. package/dist/server/canonicalize.d.ts.map +1 -0
  40. package/dist/server/canonicalize.js +23 -0
  41. package/dist/server/canonicalize.js.map +1 -0
  42. package/dist/server/host/tools/connectionStatus.d.ts +9 -0
  43. package/dist/server/host/tools/connectionStatus.d.ts.map +1 -0
  44. package/dist/server/host/tools/connectionStatus.js +39 -0
  45. package/dist/server/host/tools/connectionStatus.js.map +1 -0
  46. package/dist/server/index.d.ts.map +1 -1
  47. package/dist/server/index.js +35 -2
  48. package/dist/server/index.js.map +1 -1
  49. package/dist/server/inputSchemaToZod.d.ts +19 -0
  50. package/dist/server/inputSchemaToZod.d.ts.map +1 -0
  51. package/dist/server/inputSchemaToZod.js +89 -0
  52. package/dist/server/inputSchemaToZod.js.map +1 -0
  53. package/dist/server/mcpServer.d.ts.map +1 -1
  54. package/dist/server/mcpServer.js +3 -86
  55. package/dist/server/mcpServer.js.map +1 -1
  56. package/dist/shared/protocol.d.ts +9 -10
  57. package/dist/shared/protocol.d.ts.map +1 -1
  58. package/dist/shared/protocol.js +9 -1
  59. package/dist/shared/protocol.js.map +1 -1
  60. package/package.json +1 -1
@@ -10,6 +10,475 @@ const WAIT_TIMEOUT_DEFAULT = 10_000;
10
10
  const WAIT_TIMEOUT_MAX = 60_000;
11
11
  const WAIT_INTERVAL_DEFAULT = 300;
12
12
  const WAIT_INTERVAL_MIN = 100;
13
+ // Parse a name pattern: `/regex/flags` → RegExp matcher; anything else →
14
+ // exact-string matcher. Same convention as log_box__ignore.
15
+ const parseNamePattern = (raw) => {
16
+ const m = raw.match(/^\/(.+)\/([gimsuy]*)$/);
17
+ if (m && m[1] !== undefined) {
18
+ try {
19
+ const rx = new RegExp(m[1], m[2] ?? '');
20
+ return (n) => {
21
+ return rx.test(n);
22
+ };
23
+ }
24
+ catch {
25
+ return (n) => {
26
+ return n === raw;
27
+ };
28
+ }
29
+ }
30
+ return (n) => {
31
+ return n === raw;
32
+ };
33
+ };
34
+ const HOOK_DEFAULT_MAX_DEPTH = 3;
35
+ // Try to treat `current` as a React component / native view instance and
36
+ // collapse it to a compact identifier. Native views expose
37
+ // `_internalFiberInstanceHandleDEV` / `_reactInternals`; class instances
38
+ // expose `_reactInternals`. If we find a fiber, we surface its mcpId /
39
+ // testID / component name so the agent can follow up via `query` if needed.
40
+ // If it doesn't look like a component, return null to signal "serialize
41
+ // normally".
42
+ const resolveComponentRef = (current) => {
43
+ if (current === null || current === undefined)
44
+ return null;
45
+ if (typeof current !== 'object')
46
+ return null;
47
+ const obj = current;
48
+ // Pick up a fiber from the typical internal fields used by RN / React.
49
+ const fiber = obj._reactInternals ??
50
+ obj._reactInternalFiber ??
51
+ obj._internalFiberInstanceHandleDEV ??
52
+ obj._internalInstanceHandle;
53
+ const hasNativeTag = '_nativeTag' in obj;
54
+ if (!fiber && !hasNativeTag)
55
+ return null;
56
+ const out = { __componentRef: true };
57
+ if (fiber) {
58
+ const props = fiber.memoizedProps;
59
+ const mcpId = props?.['data-mcp-id'];
60
+ const testID = props?.testID;
61
+ if (mcpId)
62
+ out.mcpId = mcpId;
63
+ if (testID)
64
+ out.testID = testID;
65
+ const typeName = fiber.type?.displayName ??
66
+ fiber.type?.name;
67
+ if (typeName)
68
+ out.componentName = typeName;
69
+ }
70
+ if (hasNativeTag) {
71
+ out.nativeTag = obj._nativeTag;
72
+ const viewConfig = obj.viewConfig;
73
+ if (viewConfig?.uiViewClassName)
74
+ out.viewClass = viewConfig.uiViewClassName;
75
+ }
76
+ return out;
77
+ };
78
+ // Recognise React's effect-record shape: `{ tag: number, create: function,
79
+ // deps: null | unknown[] }` with optional `inst` / `destroy` / `next`.
80
+ // useState / useReducer / useContext memoizedState values of this exact
81
+ // shape in real user code are astronomically unlikely, so we treat this as
82
+ // a reliable "definitely not a state slot" signal.
83
+ const looksLikeEffectRecord = (raw) => {
84
+ if (!raw || typeof raw !== 'object')
85
+ return false;
86
+ const r = raw;
87
+ return (typeof r.tag === 'number' &&
88
+ typeof r.create === 'function' &&
89
+ (r.deps === null || r.deps === undefined || Array.isArray(r.deps)));
90
+ };
91
+ // Recognise the useRef shape: `{ current: X }` with NO other keys. A useState
92
+ // value that is literally an object whose sole own-key is "current" is so
93
+ // improbable in real code that we treat it as a reliable "this slot is a
94
+ // ref, not a state" signal — lets State/Custom skip ref slots that leaked in
95
+ // through custom-hook internals.
96
+ const looksLikeRefShape = (raw) => {
97
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
98
+ return false;
99
+ const keys = Object.keys(raw);
100
+ return keys.length === 1 && keys[0] === 'current';
101
+ };
102
+ // Shape-verify a hook slot's memoizedState against its expected kind. When
103
+ // a custom hook internally uses multiple built-in hooks, our static metadata
104
+ // understates the number of slots — every subsequent pairing drifts. By
105
+ // requiring a structural match before consuming a metadata entry we can
106
+ // swallow "internal" slots and keep the rest aligned. Permissive kinds
107
+ // (State / Reducer / Context / Custom) reject only obvious mis-matches
108
+ // (currently: the effect-record shape).
109
+ const shapeMatchesKind = (raw, kind) => {
110
+ switch (kind) {
111
+ case 'Ref':
112
+ return !!raw && typeof raw === 'object' && 'current' in raw;
113
+ case 'Memo':
114
+ case 'Callback':
115
+ return Array.isArray(raw) && raw.length === 2 && (raw[1] === null || Array.isArray(raw[1]));
116
+ case 'Effect':
117
+ case 'LayoutEffect':
118
+ case 'InsertionEffect':
119
+ return looksLikeEffectRecord(raw);
120
+ case 'Transition':
121
+ return Array.isArray(raw) && raw.length === 2;
122
+ case 'State':
123
+ case 'Reducer':
124
+ case 'Context':
125
+ case 'Custom':
126
+ // Permissive but not blind — drop obvious effect-node and ref-shape
127
+ // slots so State/Custom metadata doesn't swallow internals of
128
+ // preceding custom hooks.
129
+ return !looksLikeEffectRecord(raw) && !looksLikeRefShape(raw);
130
+ default:
131
+ return true;
132
+ }
133
+ };
134
+ const serializeHookValue = (raw, kind, maxDepth) => {
135
+ const walk = (v) => {
136
+ return (0, utils_1.serializeValue)(v, new WeakSet(), 0, maxDepth);
137
+ };
138
+ switch (kind) {
139
+ case 'Ref': {
140
+ if (!raw || typeof raw !== 'object' || !('current' in raw)) {
141
+ return walk(raw);
142
+ }
143
+ const current = raw.current;
144
+ const componentRef = resolveComponentRef(current);
145
+ if (componentRef) {
146
+ return { current: componentRef };
147
+ }
148
+ return { current: walk(current) };
149
+ }
150
+ case 'Memo':
151
+ case 'Callback':
152
+ if (Array.isArray(raw) && raw.length === 2) {
153
+ return { deps: raw[1], value: walk(raw[0]) };
154
+ }
155
+ return walk(raw);
156
+ case 'Effect':
157
+ case 'LayoutEffect':
158
+ case 'InsertionEffect': {
159
+ // React's hook slot for effects holds { tag, create, destroy, deps, next }.
160
+ // Only `deps` is safe and useful to surface.
161
+ const effect = raw;
162
+ return effect && typeof effect === 'object' && 'deps' in effect
163
+ ? { deps: effect.deps ?? null }
164
+ : null;
165
+ }
166
+ default:
167
+ return walk(raw);
168
+ }
169
+ };
170
+ // Estimate how many slots in fiber.memoizedState a hook function consumes.
171
+ // Used by the slot-walker to advance past unannotated black-box library
172
+ // hooks (e.g. `useSelector` from react-redux) which our babel plugin couldn't
173
+ // expand statically — without this they'd consume only one fiber slot during
174
+ // alignment, drifting the rest of the metadata out of sync.
175
+ //
176
+ // Three cascading strategies, falling through on miss:
177
+ // 1. `fn.__mcp_hooks` recursive — accurate for any hook our plugin saw.
178
+ // Custom sub-entries recurse; built-in entries count as 1.
179
+ // 2. `fn.toString()` regex — counts `useXxx(` calls in the source. Works
180
+ // even after Metro bundling because hook references survive as
181
+ // property accesses (`(0, _react.useState)(...)`) — property names
182
+ // aren't mangled. Underestimates when nested customs themselves expand
183
+ // to multiple slots, but better than 1.
184
+ // 3. Default 1 — native functions, bound functions, or sources we can't
185
+ // parse. Same as the original behavior.
186
+ //
187
+ // Cached per-function via WeakMap so cost is paid once per hook fn per
188
+ // session.
189
+ const HOOK_SLOTS_CACHE = new WeakMap();
190
+ const HOOK_NAME_RE = /\buse[A-Z]\w*\s*\(/g;
191
+ const STRING_LITERAL_RE = /(['"`])(?:\\.|(?!\1).)*\1/g;
192
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
193
+ const LINE_COMMENT_RE = /\/\/[^\n]*/g;
194
+ const countHookSlots = (fn, depth = 0, seen) => {
195
+ if (depth > 8)
196
+ return 1;
197
+ if (typeof fn !== 'function')
198
+ return 1;
199
+ const fnObj = fn;
200
+ const cached = HOOK_SLOTS_CACHE.get(fnObj);
201
+ if (cached !== undefined)
202
+ return cached;
203
+ const localSeen = seen ?? new WeakSet();
204
+ if (localSeen.has(fnObj))
205
+ return 1;
206
+ localSeen.add(fnObj);
207
+ // (1) Annotated metadata: recurse into sub-entries, summing their slot
208
+ // counts. Each built-in entry contributes 1; each Custom contributes
209
+ // however many its own fn does (recurse).
210
+ const annotated = fn.__mcp_hooks;
211
+ if (Array.isArray(annotated)) {
212
+ let total = 0;
213
+ for (const entry of annotated) {
214
+ if (entry && entry.kind === 'Custom' && typeof entry.fn === 'function') {
215
+ total += countHookSlots(entry.fn, depth + 1, localSeen);
216
+ }
217
+ else {
218
+ total += 1;
219
+ }
220
+ }
221
+ const result = Math.max(total, 1);
222
+ HOOK_SLOTS_CACHE.set(fnObj, result);
223
+ return result;
224
+ }
225
+ // (2) toString-based parsing. Strip strings/comments first to avoid
226
+ // matching `'useState'` inside literals. Each `useXxx(` occurrence in
227
+ // remaining source counts as one hook call (≥ 1 slot). Custom hook calls
228
+ // we encounter here can't be resolved further (no scope binding from the
229
+ // outer function), so we bottom out at 1 slot per occurrence — still
230
+ // beats the original constant-1 fallback when the source has multiple
231
+ // hook calls (e.g. `useSyncExternalStoreWithSelector` internals).
232
+ let src;
233
+ try {
234
+ src = Function.prototype.toString.call(fn);
235
+ }
236
+ catch {
237
+ HOOK_SLOTS_CACHE.set(fnObj, 1);
238
+ return 1;
239
+ }
240
+ if (!src || src.includes('[native code]')) {
241
+ HOOK_SLOTS_CACHE.set(fnObj, 1);
242
+ return 1;
243
+ }
244
+ const stripped = src
245
+ .replace(STRING_LITERAL_RE, '""')
246
+ .replace(BLOCK_COMMENT_RE, '')
247
+ .replace(LINE_COMMENT_RE, '');
248
+ HOOK_NAME_RE.lastIndex = 0;
249
+ const matches = stripped.match(HOOK_NAME_RE);
250
+ const result = matches ? matches.length : 1;
251
+ HOOK_SLOTS_CACHE.set(fnObj, result);
252
+ return result;
253
+ };
254
+ // Flatten a metadata array by recursively inlining custom-hook sub-metadata.
255
+ // Stops on cycles (hook references itself) and on Custom entries whose `fn`
256
+ // isn't annotated (library hooks that bypassed the babel plugin — usually
257
+ // pre-compiled node_modules). Such unannotated entries stay as single
258
+ // records and rely on shape-check for alignment.
259
+ //
260
+ // Custom entries with annotated `fn` produce TWO records: a parent (marked
261
+ // `expanded: true`, no slot consumption) followed by all flattened
262
+ // children. This keeps the call-site visible in the output — without it
263
+ // the agent would see e.g. `wrapperAnimStyle.areAnimationsActive` deep
264
+ // in `via:` but never the `wrapperAnimStyle = useAnimatedStyle(...)`
265
+ // invocation that owns those slots.
266
+ const flattenHookMeta = (meta, via = [], seen = new WeakSet(), maxDepth = Infinity) => {
267
+ const out = [];
268
+ for (const entry of meta) {
269
+ const fn = entry.fn;
270
+ const sub = fn && typeof fn === 'function' && Array.isArray(fn.__mcp_hooks) ? fn.__mcp_hooks : undefined;
271
+ // Stop expanding once `via.length` would reach the cap — at that point
272
+ // the current entry is treated as a leaf (Custom record without
273
+ // children). The slot-walker still pairs it with one slot, so output
274
+ // stays internally consistent.
275
+ if (sub && !seen.has(fn) && via.length < maxDepth) {
276
+ seen.add(fn);
277
+ out.push({ ...entry, expanded: true, via });
278
+ out.push(...flattenHookMeta(sub, [...via, entry.name], seen, maxDepth));
279
+ seen.delete(fn);
280
+ }
281
+ else {
282
+ out.push({ ...entry, via });
283
+ }
284
+ }
285
+ return out;
286
+ };
287
+ const flatHooksToTree = (flat) => {
288
+ const root = [];
289
+ // Stack of currently-open parents, indexed by their depth (= via.length
290
+ // of THEIR own entry, since their children sit at via.length + 1).
291
+ const parents = [];
292
+ for (const entry of flat) {
293
+ const depth = entry.via?.length ?? 0;
294
+ while (parents.length > depth)
295
+ parents.pop();
296
+ const node = { kind: entry.kind, name: entry.name };
297
+ if (entry.hook !== undefined)
298
+ node.hook = entry.hook;
299
+ if (entry.value !== undefined)
300
+ node.value = entry.value;
301
+ if (parents.length === 0) {
302
+ root.push(node);
303
+ }
304
+ else {
305
+ const parent = parents[parents.length - 1];
306
+ if (parent) {
307
+ parent.children = parent.children ?? [];
308
+ parent.children.push(node);
309
+ }
310
+ else {
311
+ root.push(node);
312
+ }
313
+ }
314
+ if (entry.expanded) {
315
+ // Push as the new active parent at this depth. Subsequent entries
316
+ // with via.length > depth become this node's descendants.
317
+ parents.push(node);
318
+ }
319
+ }
320
+ return root;
321
+ };
322
+ const extractHooks = (fiber, filter) => {
323
+ // React's wrapper machinery makes "where does metadata live" depend on
324
+ // the exact HOC chain. We try the most likely homes in order:
325
+ //
326
+ // 1. `fiber.type.__mcp_hooks` — bare components, FunctionDeclarations,
327
+ // and the outer memo fiber when the chain is just memo(fn).
328
+ // 2. `fiber.elementType.__mcp_hooks` — memo(fn) without compare.
329
+ // React converts the fiber to SimpleMemoComponent and rewrites
330
+ // `fiber.type` to the inner function (see `updateMemoComponent` in
331
+ // ReactFabric); our metadata sits on the outer memo wrapper, which
332
+ // survives only on `elementType`.
333
+ // 3. `fiber.type.render.__mcp_hooks` — forwardRef wrapper. React lays
334
+ // out memo(forwardRef(fn)) as three fibers (memo → forwardRef →
335
+ // function). When the user queries by displayName they tend to
336
+ // match the middle ForwardRef fiber (whose displayName resolves
337
+ // via `render.displayName`); fiber.type there is the forwardRef
338
+ // wrapper, which holds the inner fn at `.render`. The babel plugin
339
+ // put plain metadata on that fn via the FunctionDecl visitor.
340
+ // 4. `fiber.type.type.__mcp_hooks` — memo wrapper around forwardRef
341
+ // (or any non-function inner). Not the SimpleMemoComponent path,
342
+ // so `fiber.type` stays as the memo wrapper and `.type` is the
343
+ // inner forwardRef / class / etc. This catches getter installations
344
+ // on the wrapper layer too.
345
+ const candidates = [
346
+ fiber.type,
347
+ fiber.elementType,
348
+ fiber.type?.render,
349
+ fiber.type?.type,
350
+ ];
351
+ let rawMeta;
352
+ for (const c of candidates) {
353
+ const m = c?.__mcp_hooks;
354
+ if (Array.isArray(m)) {
355
+ rawMeta = m;
356
+ break;
357
+ }
358
+ }
359
+ if (!Array.isArray(rawMeta))
360
+ return null;
361
+ const meta = flattenHookMeta(rawMeta, [], new WeakSet(), filter.expansionDepth);
362
+ const out = [];
363
+ let state = fiber.memoizedState;
364
+ let metaIdx = 0;
365
+ // Walk the fiber's hook chain and pair each slot with the next metadata
366
+ // entry whose `kind` is shape-compatible with the slot. Strongly-shaped
367
+ // kinds (Ref / Memo / Callback / Effect) match only the corresponding
368
+ // React hook shape. Permissive kinds (State / Reducer / Context / Custom)
369
+ // reject obvious mismatches (effect-record, ref-shape) so they don't
370
+ // swallow slots belonging to preceding custom hooks.
371
+ //
372
+ // For Custom-leaf entries — typically black-box library hooks
373
+ // (`useSelector`, `useQuery`, etc.) where flattenHookMeta couldn't expand
374
+ // because `fn.__mcp_hooks` was missing — we estimate slot count via
375
+ // `countHookSlots` and advance the fiber chain by that many slots in one
376
+ // step. Without this, a hook that consumes 3 internal slots only
377
+ // advances by 1 in the walker, drifting all trailing entries off the end
378
+ // of the chain.
379
+ const emitEntry = (entry, rawValueSlot) => {
380
+ const { hook, kind, name, via } = entry;
381
+ const passesKind = !filter.kindsSet || filter.kindsSet.has(kind);
382
+ const passesName = !filter.nameMatchers ||
383
+ filter.nameMatchers.some((m) => {
384
+ return m(name);
385
+ });
386
+ if (!(passesKind && passesName))
387
+ return;
388
+ const record = { kind, name };
389
+ // Prefer the babel-emitted hook name; fall back to fn.name for entries
390
+ // produced by older bundles that predate the `hook` field.
391
+ const resolvedHook = hook ?? (typeof entry.fn === 'function' ? entry.fn.name : undefined);
392
+ if (resolvedHook)
393
+ record.hook = resolvedHook;
394
+ if (filter.withValues && rawValueSlot !== undefined) {
395
+ // Redaction guard: mask the value (but keep kind/name/hook visible)
396
+ // when the entry's name OR any ancestor in `via` matches a redact
397
+ // pattern. Catches both direct sensitive hooks (`password` State)
398
+ // and leaves nested under sensitive customs (a `value` field of
399
+ // `useCredentials()` won't slip through).
400
+ const isRedacted = matchesAnyRedactPattern(name, filter.redactPatterns) ||
401
+ (via?.some((v) => {
402
+ return matchesAnyRedactPattern(v, filter.redactPatterns);
403
+ }) ??
404
+ false);
405
+ if (isRedacted) {
406
+ record.value = REDACTED_VALUE;
407
+ }
408
+ else {
409
+ let value;
410
+ try {
411
+ value = serializeHookValue(rawValueSlot, kind, filter.maxDepth);
412
+ // Final cycle / non-serialisable check — the MCP bridge will
413
+ // stringify this for transport, so bail now rather than killing
414
+ // the whole response if one stray value carries a cycle past our
415
+ // WeakSet (e.g. Proxy, lazy getter, native-bridged object).
416
+ JSON.stringify(value);
417
+ }
418
+ catch {
419
+ value = '[Unserialisable value]';
420
+ }
421
+ record.value = value;
422
+ }
423
+ }
424
+ if (via && via.length > 0)
425
+ record.via = via;
426
+ if (entry.expanded)
427
+ record.expanded = true;
428
+ out.push(record);
429
+ };
430
+ const advanceState = (steps) => {
431
+ for (let i = 0; i < steps && state; i++)
432
+ state = state.next;
433
+ };
434
+ while (state && metaIdx < meta.length) {
435
+ const entry = meta[metaIdx];
436
+ if (!entry) {
437
+ metaIdx++;
438
+ continue;
439
+ }
440
+ // Expanded parent (synthetic, marks the call-site of a recursively
441
+ // expanded custom hook). Emit without consuming any fiber slot — the
442
+ // children that follow are the real slot-bearing entries.
443
+ if (entry.expanded) {
444
+ emitEntry(entry, undefined);
445
+ metaIdx++;
446
+ continue;
447
+ }
448
+ // Custom-leaf with a known fn → recurse into source to estimate slot
449
+ // count, then consume that many slots at once. The first slot's value
450
+ // is exposed (best approximation of "this hook's value"); the rest are
451
+ // skipped silently as internals of the library hook.
452
+ if (entry.kind === 'Custom' && typeof entry.fn === 'function') {
453
+ const slots = countHookSlots(entry.fn);
454
+ if (slots > 1) {
455
+ emitEntry(entry, state.memoizedState);
456
+ advanceState(slots);
457
+ metaIdx++;
458
+ continue;
459
+ }
460
+ }
461
+ if (!shapeMatchesKind(state.memoizedState, entry.kind)) {
462
+ state = state.next;
463
+ continue;
464
+ }
465
+ emitEntry(entry, state.memoizedState);
466
+ metaIdx++;
467
+ state = state.next;
468
+ }
469
+ // Any metadata entries left after the fiber chain ran dry didn't get a
470
+ // slot match. Emit them anyway (without value) so the agent at least
471
+ // sees the hook exists — this is strictly better than silently dropping,
472
+ // and helps debug alignment issues. Common cause: a preceding Custom
473
+ // hook consumed more slots than countHookSlots estimated.
474
+ while (metaIdx < meta.length) {
475
+ const entry = meta[metaIdx];
476
+ if (entry)
477
+ emitEntry(entry, undefined);
478
+ metaIdx++;
479
+ }
480
+ return filter.format === 'tree' ? flatHooksToTree(out) : out;
481
+ };
13
482
  const FIND_SCHEMA = {
14
483
  index: {
15
484
  description: '0-based index when several components match (default: 0).',
@@ -25,6 +494,38 @@ const FIND_SCHEMA = {
25
494
  type: 'string',
26
495
  },
27
496
  };
497
+ // Default redact patterns — applied to a hook's `name` AND every entry in
498
+ // its `via` chain so values stay masked even when nested under a sensitive
499
+ // custom hook (e.g. a leaf `value` inside a `useAuth()` expansion). Tuned
500
+ // to match real-world variable names without over-matching innocent ones:
501
+ // `Pin$` is anchored so it doesn't catch "Spinner"; broad terms like
502
+ // `auth` are deliberately omitted (would catch `isAuthenticated`).
503
+ const DEFAULT_REDACT_HOOK_NAMES = [
504
+ /password/i,
505
+ /token/i,
506
+ /jwt/i,
507
+ /secret/i,
508
+ /Pin$/,
509
+ /credential/i,
510
+ /apiKey/i,
511
+ /authorization/i,
512
+ ];
513
+ const REDACTED_VALUE = '[redacted]';
514
+ const escapeRegExp = (s) => {
515
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
516
+ };
517
+ const compileRedactPatterns = (raw) => {
518
+ return raw.map((p) => {
519
+ return typeof p === 'string' ? new RegExp(escapeRegExp(p), 'i') : p;
520
+ });
521
+ };
522
+ const matchesAnyRedactPattern = (name, patterns) => {
523
+ for (const p of patterns) {
524
+ if (p.test(name))
525
+ return true;
526
+ }
527
+ return false;
528
+ };
28
529
  const resolveScreenFiber = (runtime) => {
29
530
  const nav = runtime.navigationRef;
30
531
  if (!nav || typeof nav.getCurrentRoute !== 'function')
@@ -143,6 +644,14 @@ const fiberTreeModule = (options) => {
143
644
  (0, utils_1.setRootRef)(options.rootRef);
144
645
  }
145
646
  const navigationRef = options?.navigationRef;
647
+ // Compile the redact pattern list once at module init. Precedence:
648
+ // - `redactHookNames` provided → use it verbatim (replace mode).
649
+ // - `additionalRedactHookNames` provided → defaults + user's.
650
+ // - Neither → defaults.
651
+ // Pass `redactHookNames: []` to disable redaction entirely.
652
+ const redactPatterns = compileRedactPatterns(options?.redactHookNames !== undefined
653
+ ? options.redactHookNames
654
+ : [...DEFAULT_REDACT_HOOK_NAMES, ...(options?.additionalRedactHookNames ?? [])]);
146
655
  // Root-version keyed cache for `runQueryChain`. When React commits, the
147
656
  // HostRoot fiber swaps — so a mismatched pointer is proof the tree changed
148
657
  // and the cached match set for the same steps is no longer valid.
@@ -250,13 +759,15 @@ STEP CRITERIA
250
759
  index — pick N-th match from this step; otherwise all matches fan out into the next step.
251
760
 
252
761
  SELECT (output fields)
253
- Default ["mcpId", "name", "testID"] — props and bounds are opt-in.
762
+ Default ["mcpId", "name", "testID"] — props, bounds, hooks are opt-in.
254
763
  bounds: { x, y, width, height, centerX, centerY } in PHYSICAL pixels,
255
764
  top-left origin. null when the fiber has no mounted host view. centerX/
256
765
  centerY feed straight into host__tap.
257
766
  props: full serialized props (heavy). Pair with propsInclude:
258
767
  ["key1","key2"] to keep only the props you actually need and avoid
259
768
  pulling large style maps, data arrays, or nested element trees.
769
+ hooks: the component's hooks. Each entry { kind, name, hook?, via?,
770
+ expanded?, value? }; configure via hooksInclude.
260
771
 
261
772
  RESPONSE
262
773
  { matches: [...], total, truncated? } — total is the unrestricted match
@@ -508,6 +1019,23 @@ TIPS
508
1019
  const propsInclude = Array.isArray(args.propsInclude)
509
1020
  ? new Set(args.propsInclude)
510
1021
  : null;
1022
+ const hooksIncludeRaw = args.hooksInclude;
1023
+ const hookKindsSet = hooksIncludeRaw && Array.isArray(hooksIncludeRaw.kinds)
1024
+ ? new Set(hooksIncludeRaw.kinds)
1025
+ : null;
1026
+ const hookNameMatchers = hooksIncludeRaw && Array.isArray(hooksIncludeRaw.names)
1027
+ ? hooksIncludeRaw.names.map(parseNamePattern)
1028
+ : null;
1029
+ const hookWithValues = hooksIncludeRaw?.withValues === true;
1030
+ const hookMaxDepth = typeof hooksIncludeRaw?.maxDepthInValues === 'number' &&
1031
+ hooksIncludeRaw.maxDepthInValues >= 0
1032
+ ? Math.min(Math.floor(hooksIncludeRaw.maxDepthInValues), 8)
1033
+ : HOOK_DEFAULT_MAX_DEPTH;
1034
+ const hookExpansionDepth = typeof hooksIncludeRaw?.expansionDepth === 'number' &&
1035
+ hooksIncludeRaw.expansionDepth >= 0
1036
+ ? Math.floor(hooksIncludeRaw.expansionDepth)
1037
+ : Infinity;
1038
+ const hookFormat = hooksIncludeRaw?.format === 'tree' ? 'tree' : 'flat';
511
1039
  const runtime = { navigationRef, root };
512
1040
  const runOnce = async (useCache) => {
513
1041
  const rawMatches = runCachedQuery(runtime, steps, useCache);
@@ -567,6 +1095,17 @@ TIPS
567
1095
  if (fields.has('testID')) {
568
1096
  result.testID = fiber.memoizedProps?.testID;
569
1097
  }
1098
+ if (fields.has('hooks')) {
1099
+ result.hooks = extractHooks(fiber, {
1100
+ expansionDepth: hookExpansionDepth,
1101
+ format: hookFormat,
1102
+ kindsSet: hookKindsSet,
1103
+ maxDepth: hookMaxDepth,
1104
+ nameMatchers: hookNameMatchers,
1105
+ redactPatterns,
1106
+ withValues: hookWithValues,
1107
+ });
1108
+ }
570
1109
  return result;
571
1110
  }));
572
1111
  return truncated ? { matches, total, truncated: true } : { matches, total };
@@ -653,6 +1192,18 @@ TIPS
653
1192
  description: 'Drop wrapper cascades — a fiber is removed when any of its ancestors is also in the match set (PressableView → Pressable → View → RCTView collapses to the topmost). Independent siblings with overlapping bounds are kept. Default true; pass false to keep every match.',
654
1193
  type: 'boolean',
655
1194
  },
1195
+ hooksInclude: {
1196
+ description: 'When select includes "hooks": `kinds` (State | Reducer | Memo | Callback | Ref | Effect | LayoutEffect | InsertionEffect | Context | Transition | DeferredValue | Id | SyncExternalStore | ImperativeHandle | Custom) and `names` (exact or `/regex/flags`) filter the list. Each entry carries { kind, name, hook?, via?, expanded? } — `hook` is the source-level hook function (`useState`, `useAnimatedStyle`); `expanded: true` marks a parent custom-hook call whose sub-hooks follow. Pass `withValues: true` for resolved values. `maxDepthInValues` caps value recursion (default 3, max 8). `expansionDepth` caps how deep custom hooks recurse — 0 = no expansion (top-level only), 1 = one level, default Infinity. `format: "tree"` returns nested `children:` instead of flat `via:`.',
1197
+ examples: [
1198
+ { kinds: ['State', 'Memo'] },
1199
+ { names: ['count', 'scrollRef'], withValues: true },
1200
+ { kinds: ['State'], names: ['/^is/'], withValues: true },
1201
+ { expansionDepth: 0 },
1202
+ { format: 'tree', withValues: true },
1203
+ { expansionDepth: 1, format: 'tree' },
1204
+ ],
1205
+ type: 'object',
1206
+ },
656
1207
  limit: {
657
1208
  description: `Max matches to return (default ${QUERY_LIMIT_DEFAULT}, max ${QUERY_LIMIT_MAX}). truncated: true is added when total exceeds limit.`,
658
1209
  type: 'number',
@@ -670,10 +1221,11 @@ TIPS
670
1221
  type: 'array',
671
1222
  },
672
1223
  select: {
673
- description: `Output fields: mcpId, name, testID, props, bounds. Default ${JSON.stringify(QUERY_DEFAULT_FIELDS)}.`,
1224
+ description: `Output fields: mcpId, name, testID, props, bounds, hooks. Default ${JSON.stringify(QUERY_DEFAULT_FIELDS)}.`,
674
1225
  examples: [
675
1226
  ['mcpId', 'name', 'bounds'],
676
1227
  ['mcpId', 'name', 'props'],
1228
+ ['mcpId', 'hooks'],
677
1229
  ],
678
1230
  type: 'array',
679
1231
  },