what-devtools-mcp 0.6.0 → 0.6.2

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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Extended MCP tool definitions for what-devtools-mcp.
3
- * 8 tools: component tree, dependency graph, eval, DOM inspect,
4
- * route info, diagnostics, diff snapshot, navigate.
3
+ * 13 tools: component tree, dependency graph, eval, DOM inspect,
4
+ * route info, diagnostics, diff snapshot, navigate, explain, signal trace,
5
+ * visual inspect (what_look), page map (what_page_map), screenshot (what_screenshot).
5
6
  *
6
7
  * These supplement the 10 base tools in tools.js.
7
8
  */
@@ -114,6 +115,21 @@ export function registerExtendedTools(server, bridge) {
114
115
  byId.set(c.id, { ...c, children: [] });
115
116
  }
116
117
 
118
+ // Check if parentIds are available (framework may not track them)
119
+ const hasParentInfo = components.some(c => c.parentId != null);
120
+
121
+ if (!hasParentInfo && components.length > 1) {
122
+ // Framework doesn't track parent-child relationships.
123
+ // Infer: lowest-ID component is the root, all others are its children.
124
+ // This gives a 1-level tree which is better than a flat list.
125
+ const sorted = [...byId.values()].sort((a, b) => a.id - b.id);
126
+ const root = sorted[0];
127
+ for (let i = 1; i < sorted.length; i++) {
128
+ sorted[i].parentId = root.id;
129
+ root.children.push(sorted[i]);
130
+ }
131
+ }
132
+
117
133
  // Build parent-child links
118
134
  const roots = [];
119
135
  for (const node of byId.values()) {
@@ -123,6 +139,15 @@ export function registerExtendedTools(server, bridge) {
123
139
  roots.push(node);
124
140
  }
125
141
  }
142
+ // Deduplicate children (inferred path may have already added them)
143
+ for (const node of byId.values()) {
144
+ const seen = new Set();
145
+ node.children = node.children.filter(c => {
146
+ if (seen.has(c.id)) return false;
147
+ seen.add(c.id);
148
+ return true;
149
+ });
150
+ }
126
151
 
127
152
  // Attach signal/effect counts per component
128
153
  const signals = snapshot.signals || [];
@@ -328,7 +353,17 @@ export function registerExtendedTools(server, bridge) {
328
353
  const id = Number(idStr);
329
354
  if (type === 'signal') {
330
355
  const s = signalMap.get(id);
331
- nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value: s?.value });
356
+ // Truncate values to avoid token waste graph topology is what matters
357
+ const raw = s?.value;
358
+ let value;
359
+ if (raw == null) value = raw;
360
+ else if (typeof raw === 'string') value = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;
361
+ else if (Array.isArray(raw)) value = `Array(${raw.length})`;
362
+ else if (typeof raw === 'object') {
363
+ const json = JSON.stringify(raw);
364
+ value = json.length > 80 ? json.slice(0, 80) + '…' : raw;
365
+ } else value = raw;
366
+ nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value });
332
367
  } else {
333
368
  const e = effectMap.get(id);
334
369
  nodes.push({ type: 'effect', id, name: e?.name || `effect_${id}`, runCount: e?.runCount });
@@ -352,14 +387,39 @@ export function registerExtendedTools(server, bridge) {
352
387
  // Tool 3 — what_eval
353
388
  // ---------------------------------------------------------------------------
354
389
 
390
+ // Guard: eval only works when explicitly enabled via --unsafe-eval flag or
391
+ // WHAT_UNSAFE_EVAL=1 environment variable. Disabled by default.
392
+ const unsafeEvalEnabled = process.argv.includes('--unsafe-eval') ||
393
+ process.env.WHAT_UNSAFE_EVAL === '1' ||
394
+ process.env.WHAT_UNSAFE_EVAL === 'true';
395
+
355
396
  server.tool(
356
397
  'what_eval',
357
- 'Execute JavaScript in the browser context. Has access to window, document, __WHAT_DEVTOOLS__, and __WHAT_CORE__. Use for debugging scenarios not covered by other tools. Dev-only.',
398
+ 'WARNING: Executes ARBITRARY JavaScript in the browser context. This is a security-sensitive tool that can run any code with full access to window, document, __WHAT_DEVTOOLS__, and __WHAT_CORE__. Disabled by default — must be explicitly enabled with --unsafe-eval flag or WHAT_UNSAFE_EVAL=1 env var. Use for debugging scenarios not covered by other tools. Dev-only.',
358
399
  {
359
400
  code: z.string().describe('JavaScript code to execute in the browser. Return a value to see it in the response.'),
360
401
  timeout: z.number().optional().default(5000).describe('Max execution time in ms (default: 5000, max: 30000)'),
361
402
  },
362
403
  async ({ code, timeout }) => {
404
+ // Allow safe read-only property access without the unsafe flag
405
+ const trimmed = (code || '').trim();
406
+ const isSafeRead = /^[\w.[\]'"]+$/.test(trimmed) && !trimmed.includes('=') ||
407
+ /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(trimmed) ||
408
+ /^window\.(innerWidth|innerHeight|devicePixelRatio|outerWidth|outerHeight)$/.test(trimmed) ||
409
+ /^navigator\.(userAgent|language|platform|onLine|hardwareConcurrency)$/.test(trimmed) ||
410
+ /^location\.(href|hostname|pathname|protocol|port|origin)$/.test(trimmed) ||
411
+ /^screen\.(width|height|availWidth|availHeight|colorDepth)$/.test(trimmed);
412
+
413
+ if (!unsafeEvalEnabled && !isSafeRead) {
414
+ return errorResponse(
415
+ 'what_eval is disabled for arbitrary code. Safe read-only expressions (document.title, window.innerWidth, etc.) work without the flag.',
416
+ [
417
+ 'Safe expressions: document.title, window.innerWidth, navigator.userAgent, location.href, screen.width',
418
+ 'For full eval, start the MCP server with --unsafe-eval or WHAT_UNSAFE_EVAL=1',
419
+ ]
420
+ );
421
+ }
422
+
363
423
  if (!bridge.isConnected()) return noConnection('what_eval');
364
424
 
365
425
  const clampedTimeout = Math.min(Math.max(timeout || 5000, 100), 30000);
@@ -493,6 +553,24 @@ export function registerExtendedTools(server, bridge) {
493
553
  } else {
494
554
  healthy.push({ category: 'errors', message: 'No runtime errors captured.' });
495
555
  }
556
+
557
+ // Check for hydration mismatches
558
+ const hydrationMismatches = snapshot.hydrationMismatches || [];
559
+ if (hydrationMismatches.length > 0) {
560
+ issues.push({
561
+ severity: 'error',
562
+ category: 'hydration',
563
+ message: `${hydrationMismatches.length} hydration mismatch${hydrationMismatches.length !== 1 ? 'es' : ''} detected.`,
564
+ details: hydrationMismatches.slice(-5).map(m => ({
565
+ component: m.component,
566
+ expected: m.expected,
567
+ actual: m.actual,
568
+ })),
569
+ suggestion: 'Avoid browser-only APIs (window, localStorage) in initial render. Use onMount() for client-only code.',
570
+ });
571
+ } else {
572
+ healthy.push({ category: 'hydration', message: 'No hydration mismatches detected.' });
573
+ }
496
574
  }
497
575
 
498
576
  // --- Performance checks ---
@@ -735,16 +813,23 @@ export function registerExtendedTools(server, bridge) {
735
813
  ? 'No changes detected since baseline.'
736
814
  : parts.join(', ') + '.';
737
815
 
816
+ // Cap large lists to save tokens — show count + sample
817
+ const cap = (arr, limit = 10) => arr.length <= limit ? arr : {
818
+ count: arr.length,
819
+ sample: arr.slice(0, limit),
820
+ truncated: arr.length - limit,
821
+ };
822
+
738
823
  return ok({
739
824
  action: 'diff',
740
825
  signalsChanged,
741
- signalsAdded,
742
- signalsRemoved,
826
+ signalsAdded: cap(signalsAdded),
827
+ signalsRemoved: cap(signalsRemoved),
743
828
  effectsTriggered,
744
- effectsAdded,
745
- effectsRemoved,
746
- componentsAdded,
747
- componentsRemoved,
829
+ effectsAdded: cap(effectsAdded),
830
+ effectsRemoved: cap(effectsRemoved),
831
+ componentsAdded: cap(componentsAdded),
832
+ componentsRemoved: cap(componentsRemoved),
748
833
  errorsNew: errorsNew.length,
749
834
  totalChanges,
750
835
  summary,
@@ -786,4 +871,335 @@ export function registerExtendedTools(server, bridge) {
786
871
  }
787
872
  }
788
873
  );
874
+
875
+ // ---------------------------------------------------------------------------
876
+ // Tool 9 — what_explain
877
+ // ---------------------------------------------------------------------------
878
+
879
+ server.tool(
880
+ 'what_explain',
881
+ 'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool.',
882
+ {
883
+ componentId: z.number().describe('Component ID to explain (from what_components)'),
884
+ includeDOM: z.boolean().optional().default(true).describe('Include rendered DOM output (default: true)'),
885
+ domDepth: z.number().optional().default(2).describe('Max DOM depth if includeDOM is true (default: 2)'),
886
+ },
887
+ async ({ componentId, includeDOM, domDepth }) => {
888
+ const { snapshot, err } = await freshSnapshot('what_explain');
889
+ if (err) return err;
890
+
891
+ const components = snapshot.components || [];
892
+ const comp = components.find(c => c.id === componentId);
893
+ if (!comp) {
894
+ return errorResponse(`Component ${componentId} not found.`, ['Use what_components to list available IDs.']);
895
+ }
896
+
897
+ // Signals belonging to this component
898
+ const signals = (snapshot.signals || []).filter(s => s.componentId === componentId);
899
+
900
+ // Effects belonging to this component
901
+ const signalMap = new Map((snapshot.signals || []).map(s => [s.id, s]));
902
+ const effects = (snapshot.effects || []).filter(e => e.componentId === componentId).map(e => ({
903
+ ...e,
904
+ depSignalNames: (e.depSignalIds || []).map(sid => signalMap.get(sid)?.name || `signal_${sid}`),
905
+ }));
906
+
907
+ // DOM output (if requested and bridge connected)
908
+ let dom = null;
909
+ if (includeDOM) {
910
+ try {
911
+ const domResult = await bridge.sendCommand('dom-inspect', { componentId, depth: domDepth || 2 });
912
+ if (!domResult.error) dom = { html: (domResult.html || '').substring(0, 2000), structure: domResult.structure };
913
+ } catch {}
914
+ }
915
+
916
+ // Errors associated with this component
917
+ const errors = bridge.getErrors().filter(e => e.componentId === componentId || e.component === comp.name);
918
+
919
+ // Build summary
920
+ const sigPreview = signals.slice(0, 5).map(s => {
921
+ const val = JSON.stringify(s.value);
922
+ return `${s.name}=${val && val.length > 30 ? val.slice(0, 27) + '...' : val}`;
923
+ }).join(', ');
924
+ const effPreview = effects.slice(0, 3).map(e => `${e.name || 'effect_' + e.id} (ran ${e.runCount || 0}x)`).join(', ');
925
+
926
+ // Add source file hint based on component name
927
+ const sourceHint = `Component source files are typically in the same directory as the app entry point. Use file search to find: ${comp.name}`;
928
+
929
+ const summary = `${comp.name}: ${signals.length} signals (${sigPreview || 'none'}), ` +
930
+ `${effects.length} effects (${effPreview || 'none'})` +
931
+ (errors.length ? `, ${errors.length} errors` : '') +
932
+ (dom ? `, DOM: ${(dom.html || '').substring(0, 80)}...` : '') +
933
+ `. ${sourceHint}`;
934
+
935
+ return ok({
936
+ summary,
937
+ component: { id: comp.id, name: comp.name, parentId: comp.parentId },
938
+ sourceHint,
939
+ signals,
940
+ effects,
941
+ dom,
942
+ errors: errors.length > 0 ? errors : [],
943
+ counts: { signals: signals.length, effects: effects.length, errors: errors.length },
944
+ });
945
+ }
946
+ );
947
+
948
+ // ---------------------------------------------------------------------------
949
+ // Tool 10 — what_signal_trace
950
+ // ---------------------------------------------------------------------------
951
+
952
+ server.tool(
953
+ 'what_signal_trace',
954
+ 'Trace why a signal changed. Shows which effects wrote to this signal and the causal chain of signal dependencies. The debugging question every developer asks: "why did this value change?"',
955
+ {
956
+ signalId: z.number().describe('Signal ID to trace (from what_signals)'),
957
+ depth: z.number().optional().default(2).describe('Causal chain depth — how many levels of effect->signal->effect to trace (default: 2)'),
958
+ },
959
+ async ({ signalId, depth }) => {
960
+ const { snapshot, err } = await freshSnapshot('what_signal_trace');
961
+ if (err) return err;
962
+
963
+ const signals = snapshot.signals || [];
964
+ const effects = snapshot.effects || [];
965
+ const signalMap = new Map(signals.map(s => [s.id, s]));
966
+ const effectMap = new Map(effects.map(e => [e.id, e]));
967
+
968
+ const sig = signalMap.get(signalId);
969
+ if (!sig) {
970
+ return errorResponse(`Signal ${signalId} not found.`, ['Use what_signals to list available signal IDs.']);
971
+ }
972
+
973
+ // Get writer info from browser
974
+ let writers = { recentWrites: [], totalWrites: 0 };
975
+ try {
976
+ writers = await bridge.sendCommand('get-signal-writers', { signalId }, 5000);
977
+ if (writers.error) writers = { recentWrites: [], totalWrites: 0, note: writers.error };
978
+ } catch {}
979
+
980
+ // Build causal chain
981
+ // For each writer effect, find what signals it depends on
982
+ const chain = [];
983
+ const visited = new Set();
984
+
985
+ function traceEffect(effectId, currentDepth) {
986
+ if (currentDepth > (depth || 2) || visited.has(`effect:${effectId}`)) return null;
987
+ visited.add(`effect:${effectId}`);
988
+
989
+ const eff = effectMap.get(effectId);
990
+ if (!eff) return null;
991
+
992
+ const deps = (eff.depSignalIds || []).map(sid => {
993
+ const depSig = signalMap.get(sid);
994
+ return { id: sid, name: depSig?.name || `signal_${sid}`, value: depSig?.value };
995
+ });
996
+
997
+ return {
998
+ effectId: eff.id,
999
+ effectName: eff.name || `effect_${eff.id}`,
1000
+ runCount: eff.runCount,
1001
+ dependsOn: deps,
1002
+ };
1003
+ }
1004
+
1005
+ // Trace from recent writes
1006
+ for (const write of (writers.recentWrites || []).slice(-5)) {
1007
+ const entry = {
1008
+ timestamp: write.timestamp,
1009
+ previousValue: write.previousValue,
1010
+ newValue: write.newValue,
1011
+ };
1012
+ if (write.writerEffect) {
1013
+ entry.causedBy = traceEffect(write.writerEffect.id, 1);
1014
+ }
1015
+ chain.push(entry);
1016
+ }
1017
+
1018
+ // Also show which effects READ this signal (downstream)
1019
+ const downstream = effects
1020
+ .filter(e => (e.depSignalIds || []).includes(signalId))
1021
+ .map(e => ({ id: e.id, name: e.name || `effect_${e.id}`, runCount: e.runCount }));
1022
+
1023
+ // Build summary
1024
+ const writerNames = chain
1025
+ .filter(c => c.causedBy)
1026
+ .map(c => c.causedBy.effectName)
1027
+ .filter((v, i, a) => a.indexOf(v) === i); // unique
1028
+
1029
+ const summary = writerNames.length > 0
1030
+ ? `Signal "${sig.name}" (current: ${JSON.stringify(sig.value)}) was written by: ${writerNames.join(', ')}. ` +
1031
+ `${downstream.length} effect${downstream.length !== 1 ? 's' : ''} read this signal.`
1032
+ : `Signal "${sig.name}" (current: ${JSON.stringify(sig.value)}). No recent write events captured. ` +
1033
+ `${downstream.length} effect${downstream.length !== 1 ? 's' : ''} read this signal.`;
1034
+
1035
+ return ok({
1036
+ summary,
1037
+ signalId,
1038
+ signalName: sig.name,
1039
+ currentValue: sig.value,
1040
+ recentWrites: chain,
1041
+ downstream,
1042
+ totalWritesCaptured: writers.totalWrites || 0,
1043
+ nextSteps: [
1044
+ chain.length === 0 ? 'Use what_watch to capture events, then call what_signal_trace again.' : null,
1045
+ downstream.length > 0 ? `Use what_dependency_graph with signalId=${signalId} to see the full reactive graph.` : null,
1046
+ 'Use what_set_signal to test what happens when this signal changes.',
1047
+ ].filter(Boolean),
1048
+ });
1049
+ }
1050
+ );
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // Tool 11 — what_look (visual inspection without image)
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ server.tool(
1057
+ 'what_look',
1058
+ 'Get computed visual info about a component WITHOUT an image: bounding rect, key CSS styles, text content, child element types, accessibility info, and layout classification. Much cheaper than a screenshot (~300-500 tokens). Use this first before what_screenshot.',
1059
+ {
1060
+ componentId: z.number().describe('Component ID (from what_components)'),
1061
+ },
1062
+ async ({ componentId }) => {
1063
+ if (!bridge.isConnected()) return noConnection('what_look');
1064
+
1065
+ try {
1066
+ const result = await bridge.sendCommand('visual-inspect', { componentId }, 5000);
1067
+ if (result.error) {
1068
+ return errorResponse(result.error, ['Use what_components to list available IDs.']);
1069
+ }
1070
+
1071
+ const { componentName, boundingRect, styles, textContent, childElements, totalChildren, layout, viewport, accessibility } = result;
1072
+
1073
+ const styleDesc = Object.entries(styles || {}).map(([k, v]) => `${k}: ${v}`).join(', ');
1074
+ const childDesc = Object.entries(childElements || {}).map(([k, v]) => `${v} ${k}${v > 1 ? 's' : ''}`).join(', ');
1075
+
1076
+ const summary = `${componentName}: ${boundingRect.width}×${boundingRect.height}px at (${boundingRect.x},${boundingRect.y}). ` +
1077
+ `Layout: ${layout}. ` +
1078
+ (childDesc ? `Contains: ${childDesc}. ` : '') +
1079
+ (textContent && textContent !== '(empty)' ? `Text: "${textContent.substring(0, 80)}${textContent.length > 80 ? '...' : ''}"` : 'No text content.');
1080
+
1081
+ return ok({
1082
+ summary,
1083
+ component: componentName,
1084
+ boundingRect,
1085
+ layout,
1086
+ styles,
1087
+ textContent,
1088
+ childElements,
1089
+ totalChildren,
1090
+ accessibility,
1091
+ viewport,
1092
+ });
1093
+ } catch (e) {
1094
+ return errorResponse(`Failed: ${e.message}`, ['Check what_connection_status']);
1095
+ }
1096
+ }
1097
+ );
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Tool 12 — what_page_map (full page layout skeleton)
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ server.tool(
1104
+ 'what_page_map',
1105
+ 'Get a structured map of the entire visible page: landmarks, interactive elements, headings, and WhatFW component boundaries with positions. No image — pure structured text (~500-1000 tokens). Use this to understand the full page layout.',
1106
+ {
1107
+ maxElements: z.number().optional().default(200).describe('Max elements to include (default: 200)'),
1108
+ },
1109
+ async ({ maxElements }) => {
1110
+ if (!bridge.isConnected()) return noConnection('what_page_map');
1111
+
1112
+ try {
1113
+ const result = await bridge.sendCommand('page-map', { maxElements: maxElements || 200 }, 5000);
1114
+ if (result.error) {
1115
+ return errorResponse(result.error);
1116
+ }
1117
+
1118
+ const { viewport, landmarks, interactives, headings, components, totalElements } = result;
1119
+
1120
+ const summary = `Page map: ${viewport.width}×${viewport.height} viewport. ` +
1121
+ `${landmarks?.length || 0} landmarks, ` +
1122
+ `${interactives?.length || 0} interactive elements, ` +
1123
+ `${headings?.length || 0} headings, ` +
1124
+ `${components?.length || 0} WhatFW components. ` +
1125
+ `${totalElements} total elements mapped.`;
1126
+
1127
+ return ok({
1128
+ summary,
1129
+ viewport,
1130
+ landmarks: landmarks || [],
1131
+ interactives: interactives || [],
1132
+ headings: headings || [],
1133
+ components: components || [],
1134
+ totalElements,
1135
+ });
1136
+ } catch (e) {
1137
+ return errorResponse(`Failed: ${e.message}`, ['Check what_connection_status']);
1138
+ }
1139
+ }
1140
+ );
1141
+
1142
+ // ---------------------------------------------------------------------------
1143
+ // Tool 13 — what_screenshot (component-level screenshot via foreignObject SVG)
1144
+ // ---------------------------------------------------------------------------
1145
+
1146
+ server.tool(
1147
+ 'what_screenshot',
1148
+ 'Capture a component-level screenshot. Returns a base64-encoded image cropped to JUST the component bounding box (5-20KB, not a full page screenshot). Use what_look first for cheaper text-based inspection.',
1149
+ {
1150
+ componentId: z.number().describe('Component ID (from what_components)'),
1151
+ maxWidth: z.number().optional().default(400).describe('Max image width in px — smaller = faster + cheaper (default: 400)'),
1152
+ quality: z.number().optional().default(0.7).describe('JPEG quality 0.1-1.0 (default: 0.7)'),
1153
+ format: z.enum(['jpeg', 'png']).optional().default('jpeg').describe('Image format — jpeg is smaller (default: jpeg)'),
1154
+ },
1155
+ async ({ componentId, maxWidth, quality, format }) => {
1156
+ if (!bridge.isConnected()) return noConnection('what_screenshot');
1157
+
1158
+ try {
1159
+ const result = await bridge.sendCommand('component-screenshot', {
1160
+ componentId,
1161
+ maxWidth: maxWidth || 400,
1162
+ quality: quality || 0.7,
1163
+ format: format || 'jpeg',
1164
+ }, 10000); // 10s timeout for rendering
1165
+
1166
+ if (result.error) {
1167
+ return errorResponse(result.error, [
1168
+ result.fallback || 'Use what_look for text-based visual info.',
1169
+ 'Use what_dom_inspect for HTML structure.',
1170
+ ]);
1171
+ }
1172
+
1173
+ const sizeKB = Math.round(result.sizeBytes / 1024);
1174
+ const summary = `Screenshot of "${result.componentName}": ${result.width}x${result.height}px, ${sizeKB}KB ${result.format.toUpperCase()}`;
1175
+
1176
+ // Return MCP image content block + metadata text
1177
+ return {
1178
+ content: [
1179
+ {
1180
+ type: 'image',
1181
+ data: result.base64,
1182
+ mimeType: result.mimeType || (result.format === 'png' ? 'image/png' : 'image/jpeg'),
1183
+ },
1184
+ {
1185
+ type: 'text',
1186
+ text: JSON.stringify({
1187
+ summary,
1188
+ componentName: result.componentName,
1189
+ width: result.width,
1190
+ height: result.height,
1191
+ sizeKB,
1192
+ format: result.format,
1193
+ }, null, 2),
1194
+ },
1195
+ ],
1196
+ };
1197
+ } catch (e) {
1198
+ return errorResponse(`Screenshot failed: ${e.message}`, [
1199
+ 'Use what_look for text-based visual info without an image.',
1200
+ 'Use what_dom_inspect for HTML structure.',
1201
+ ]);
1202
+ }
1203
+ }
1204
+ );
789
1205
  }