what-devtools-mcp 0.6.0 → 0.7.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.
@@ -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);
@@ -735,16 +795,23 @@ export function registerExtendedTools(server, bridge) {
735
795
  ? 'No changes detected since baseline.'
736
796
  : parts.join(', ') + '.';
737
797
 
798
+ // Cap large lists to save tokens — show count + sample
799
+ const cap = (arr, limit = 10) => arr.length <= limit ? arr : {
800
+ count: arr.length,
801
+ sample: arr.slice(0, limit),
802
+ truncated: arr.length - limit,
803
+ };
804
+
738
805
  return ok({
739
806
  action: 'diff',
740
807
  signalsChanged,
741
- signalsAdded,
742
- signalsRemoved,
808
+ signalsAdded: cap(signalsAdded),
809
+ signalsRemoved: cap(signalsRemoved),
743
810
  effectsTriggered,
744
- effectsAdded,
745
- effectsRemoved,
746
- componentsAdded,
747
- componentsRemoved,
811
+ effectsAdded: cap(effectsAdded),
812
+ effectsRemoved: cap(effectsRemoved),
813
+ componentsAdded: cap(componentsAdded),
814
+ componentsRemoved: cap(componentsRemoved),
748
815
  errorsNew: errorsNew.length,
749
816
  totalChanges,
750
817
  summary,
@@ -786,4 +853,335 @@ export function registerExtendedTools(server, bridge) {
786
853
  }
787
854
  }
788
855
  );
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Tool 9 — what_explain
859
+ // ---------------------------------------------------------------------------
860
+
861
+ server.tool(
862
+ 'what_explain',
863
+ '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.',
864
+ {
865
+ componentId: z.number().describe('Component ID to explain (from what_components)'),
866
+ includeDOM: z.boolean().optional().default(true).describe('Include rendered DOM output (default: true)'),
867
+ domDepth: z.number().optional().default(2).describe('Max DOM depth if includeDOM is true (default: 2)'),
868
+ },
869
+ async ({ componentId, includeDOM, domDepth }) => {
870
+ const { snapshot, err } = await freshSnapshot('what_explain');
871
+ if (err) return err;
872
+
873
+ const components = snapshot.components || [];
874
+ const comp = components.find(c => c.id === componentId);
875
+ if (!comp) {
876
+ return errorResponse(`Component ${componentId} not found.`, ['Use what_components to list available IDs.']);
877
+ }
878
+
879
+ // Signals belonging to this component
880
+ const signals = (snapshot.signals || []).filter(s => s.componentId === componentId);
881
+
882
+ // Effects belonging to this component
883
+ const signalMap = new Map((snapshot.signals || []).map(s => [s.id, s]));
884
+ const effects = (snapshot.effects || []).filter(e => e.componentId === componentId).map(e => ({
885
+ ...e,
886
+ depSignalNames: (e.depSignalIds || []).map(sid => signalMap.get(sid)?.name || `signal_${sid}`),
887
+ }));
888
+
889
+ // DOM output (if requested and bridge connected)
890
+ let dom = null;
891
+ if (includeDOM) {
892
+ try {
893
+ const domResult = await bridge.sendCommand('dom-inspect', { componentId, depth: domDepth || 2 });
894
+ if (!domResult.error) dom = { html: (domResult.html || '').substring(0, 2000), structure: domResult.structure };
895
+ } catch {}
896
+ }
897
+
898
+ // Errors associated with this component
899
+ const errors = bridge.getErrors().filter(e => e.componentId === componentId || e.component === comp.name);
900
+
901
+ // Build summary
902
+ const sigPreview = signals.slice(0, 5).map(s => {
903
+ const val = JSON.stringify(s.value);
904
+ return `${s.name}=${val && val.length > 30 ? val.slice(0, 27) + '...' : val}`;
905
+ }).join(', ');
906
+ const effPreview = effects.slice(0, 3).map(e => `${e.name || 'effect_' + e.id} (ran ${e.runCount || 0}x)`).join(', ');
907
+
908
+ // Add source file hint based on component name
909
+ const sourceHint = `Component source files are typically in the same directory as the app entry point. Use file search to find: ${comp.name}`;
910
+
911
+ const summary = `${comp.name}: ${signals.length} signals (${sigPreview || 'none'}), ` +
912
+ `${effects.length} effects (${effPreview || 'none'})` +
913
+ (errors.length ? `, ${errors.length} errors` : '') +
914
+ (dom ? `, DOM: ${(dom.html || '').substring(0, 80)}...` : '') +
915
+ `. ${sourceHint}`;
916
+
917
+ return ok({
918
+ summary,
919
+ component: { id: comp.id, name: comp.name, parentId: comp.parentId },
920
+ sourceHint,
921
+ signals,
922
+ effects,
923
+ dom,
924
+ errors: errors.length > 0 ? errors : [],
925
+ counts: { signals: signals.length, effects: effects.length, errors: errors.length },
926
+ });
927
+ }
928
+ );
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // Tool 10 — what_signal_trace
932
+ // ---------------------------------------------------------------------------
933
+
934
+ server.tool(
935
+ 'what_signal_trace',
936
+ '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?"',
937
+ {
938
+ signalId: z.number().describe('Signal ID to trace (from what_signals)'),
939
+ depth: z.number().optional().default(2).describe('Causal chain depth — how many levels of effect->signal->effect to trace (default: 2)'),
940
+ },
941
+ async ({ signalId, depth }) => {
942
+ const { snapshot, err } = await freshSnapshot('what_signal_trace');
943
+ if (err) return err;
944
+
945
+ const signals = snapshot.signals || [];
946
+ const effects = snapshot.effects || [];
947
+ const signalMap = new Map(signals.map(s => [s.id, s]));
948
+ const effectMap = new Map(effects.map(e => [e.id, e]));
949
+
950
+ const sig = signalMap.get(signalId);
951
+ if (!sig) {
952
+ return errorResponse(`Signal ${signalId} not found.`, ['Use what_signals to list available signal IDs.']);
953
+ }
954
+
955
+ // Get writer info from browser
956
+ let writers = { recentWrites: [], totalWrites: 0 };
957
+ try {
958
+ writers = await bridge.sendCommand('get-signal-writers', { signalId }, 5000);
959
+ if (writers.error) writers = { recentWrites: [], totalWrites: 0, note: writers.error };
960
+ } catch {}
961
+
962
+ // Build causal chain
963
+ // For each writer effect, find what signals it depends on
964
+ const chain = [];
965
+ const visited = new Set();
966
+
967
+ function traceEffect(effectId, currentDepth) {
968
+ if (currentDepth > (depth || 2) || visited.has(`effect:${effectId}`)) return null;
969
+ visited.add(`effect:${effectId}`);
970
+
971
+ const eff = effectMap.get(effectId);
972
+ if (!eff) return null;
973
+
974
+ const deps = (eff.depSignalIds || []).map(sid => {
975
+ const depSig = signalMap.get(sid);
976
+ return { id: sid, name: depSig?.name || `signal_${sid}`, value: depSig?.value };
977
+ });
978
+
979
+ return {
980
+ effectId: eff.id,
981
+ effectName: eff.name || `effect_${eff.id}`,
982
+ runCount: eff.runCount,
983
+ dependsOn: deps,
984
+ };
985
+ }
986
+
987
+ // Trace from recent writes
988
+ for (const write of (writers.recentWrites || []).slice(-5)) {
989
+ const entry = {
990
+ timestamp: write.timestamp,
991
+ previousValue: write.previousValue,
992
+ newValue: write.newValue,
993
+ };
994
+ if (write.writerEffect) {
995
+ entry.causedBy = traceEffect(write.writerEffect.id, 1);
996
+ }
997
+ chain.push(entry);
998
+ }
999
+
1000
+ // Also show which effects READ this signal (downstream)
1001
+ const downstream = effects
1002
+ .filter(e => (e.depSignalIds || []).includes(signalId))
1003
+ .map(e => ({ id: e.id, name: e.name || `effect_${e.id}`, runCount: e.runCount }));
1004
+
1005
+ // Build summary
1006
+ const writerNames = chain
1007
+ .filter(c => c.causedBy)
1008
+ .map(c => c.causedBy.effectName)
1009
+ .filter((v, i, a) => a.indexOf(v) === i); // unique
1010
+
1011
+ const summary = writerNames.length > 0
1012
+ ? `Signal "${sig.name}" (current: ${JSON.stringify(sig.value)}) was written by: ${writerNames.join(', ')}. ` +
1013
+ `${downstream.length} effect${downstream.length !== 1 ? 's' : ''} read this signal.`
1014
+ : `Signal "${sig.name}" (current: ${JSON.stringify(sig.value)}). No recent write events captured. ` +
1015
+ `${downstream.length} effect${downstream.length !== 1 ? 's' : ''} read this signal.`;
1016
+
1017
+ return ok({
1018
+ summary,
1019
+ signalId,
1020
+ signalName: sig.name,
1021
+ currentValue: sig.value,
1022
+ recentWrites: chain,
1023
+ downstream,
1024
+ totalWritesCaptured: writers.totalWrites || 0,
1025
+ nextSteps: [
1026
+ chain.length === 0 ? 'Use what_watch to capture events, then call what_signal_trace again.' : null,
1027
+ downstream.length > 0 ? `Use what_dependency_graph with signalId=${signalId} to see the full reactive graph.` : null,
1028
+ 'Use what_set_signal to test what happens when this signal changes.',
1029
+ ].filter(Boolean),
1030
+ });
1031
+ }
1032
+ );
1033
+
1034
+ // ---------------------------------------------------------------------------
1035
+ // Tool 11 — what_look (visual inspection without image)
1036
+ // ---------------------------------------------------------------------------
1037
+
1038
+ server.tool(
1039
+ 'what_look',
1040
+ '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.',
1041
+ {
1042
+ componentId: z.number().describe('Component ID (from what_components)'),
1043
+ },
1044
+ async ({ componentId }) => {
1045
+ if (!bridge.isConnected()) return noConnection('what_look');
1046
+
1047
+ try {
1048
+ const result = await bridge.sendCommand('visual-inspect', { componentId }, 5000);
1049
+ if (result.error) {
1050
+ return errorResponse(result.error, ['Use what_components to list available IDs.']);
1051
+ }
1052
+
1053
+ const { componentName, boundingRect, styles, textContent, childElements, totalChildren, layout, viewport, accessibility } = result;
1054
+
1055
+ const styleDesc = Object.entries(styles || {}).map(([k, v]) => `${k}: ${v}`).join(', ');
1056
+ const childDesc = Object.entries(childElements || {}).map(([k, v]) => `${v} ${k}${v > 1 ? 's' : ''}`).join(', ');
1057
+
1058
+ const summary = `${componentName}: ${boundingRect.width}×${boundingRect.height}px at (${boundingRect.x},${boundingRect.y}). ` +
1059
+ `Layout: ${layout}. ` +
1060
+ (childDesc ? `Contains: ${childDesc}. ` : '') +
1061
+ (textContent && textContent !== '(empty)' ? `Text: "${textContent.substring(0, 80)}${textContent.length > 80 ? '...' : ''}"` : 'No text content.');
1062
+
1063
+ return ok({
1064
+ summary,
1065
+ component: componentName,
1066
+ boundingRect,
1067
+ layout,
1068
+ styles,
1069
+ textContent,
1070
+ childElements,
1071
+ totalChildren,
1072
+ accessibility,
1073
+ viewport,
1074
+ });
1075
+ } catch (e) {
1076
+ return errorResponse(`Failed: ${e.message}`, ['Check what_connection_status']);
1077
+ }
1078
+ }
1079
+ );
1080
+
1081
+ // ---------------------------------------------------------------------------
1082
+ // Tool 12 — what_page_map (full page layout skeleton)
1083
+ // ---------------------------------------------------------------------------
1084
+
1085
+ server.tool(
1086
+ 'what_page_map',
1087
+ '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.',
1088
+ {
1089
+ maxElements: z.number().optional().default(200).describe('Max elements to include (default: 200)'),
1090
+ },
1091
+ async ({ maxElements }) => {
1092
+ if (!bridge.isConnected()) return noConnection('what_page_map');
1093
+
1094
+ try {
1095
+ const result = await bridge.sendCommand('page-map', { maxElements: maxElements || 200 }, 5000);
1096
+ if (result.error) {
1097
+ return errorResponse(result.error);
1098
+ }
1099
+
1100
+ const { viewport, landmarks, interactives, headings, components, totalElements } = result;
1101
+
1102
+ const summary = `Page map: ${viewport.width}×${viewport.height} viewport. ` +
1103
+ `${landmarks?.length || 0} landmarks, ` +
1104
+ `${interactives?.length || 0} interactive elements, ` +
1105
+ `${headings?.length || 0} headings, ` +
1106
+ `${components?.length || 0} WhatFW components. ` +
1107
+ `${totalElements} total elements mapped.`;
1108
+
1109
+ return ok({
1110
+ summary,
1111
+ viewport,
1112
+ landmarks: landmarks || [],
1113
+ interactives: interactives || [],
1114
+ headings: headings || [],
1115
+ components: components || [],
1116
+ totalElements,
1117
+ });
1118
+ } catch (e) {
1119
+ return errorResponse(`Failed: ${e.message}`, ['Check what_connection_status']);
1120
+ }
1121
+ }
1122
+ );
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // Tool 13 — what_screenshot (component-level screenshot via foreignObject SVG)
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ server.tool(
1129
+ 'what_screenshot',
1130
+ '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.',
1131
+ {
1132
+ componentId: z.number().describe('Component ID (from what_components)'),
1133
+ maxWidth: z.number().optional().default(400).describe('Max image width in px — smaller = faster + cheaper (default: 400)'),
1134
+ quality: z.number().optional().default(0.7).describe('JPEG quality 0.1-1.0 (default: 0.7)'),
1135
+ format: z.enum(['jpeg', 'png']).optional().default('jpeg').describe('Image format — jpeg is smaller (default: jpeg)'),
1136
+ },
1137
+ async ({ componentId, maxWidth, quality, format }) => {
1138
+ if (!bridge.isConnected()) return noConnection('what_screenshot');
1139
+
1140
+ try {
1141
+ const result = await bridge.sendCommand('component-screenshot', {
1142
+ componentId,
1143
+ maxWidth: maxWidth || 400,
1144
+ quality: quality || 0.7,
1145
+ format: format || 'jpeg',
1146
+ }, 10000); // 10s timeout for rendering
1147
+
1148
+ if (result.error) {
1149
+ return errorResponse(result.error, [
1150
+ result.fallback || 'Use what_look for text-based visual info.',
1151
+ 'Use what_dom_inspect for HTML structure.',
1152
+ ]);
1153
+ }
1154
+
1155
+ const sizeKB = Math.round(result.sizeBytes / 1024);
1156
+ const summary = `Screenshot of "${result.componentName}": ${result.width}x${result.height}px, ${sizeKB}KB ${result.format.toUpperCase()}`;
1157
+
1158
+ // Return MCP image content block + metadata text
1159
+ return {
1160
+ content: [
1161
+ {
1162
+ type: 'image',
1163
+ data: result.base64,
1164
+ mimeType: result.mimeType || (result.format === 'png' ? 'image/png' : 'image/jpeg'),
1165
+ },
1166
+ {
1167
+ type: 'text',
1168
+ text: JSON.stringify({
1169
+ summary,
1170
+ componentName: result.componentName,
1171
+ width: result.width,
1172
+ height: result.height,
1173
+ sizeKB,
1174
+ format: result.format,
1175
+ }, null, 2),
1176
+ },
1177
+ ],
1178
+ };
1179
+ } catch (e) {
1180
+ return errorResponse(`Screenshot failed: ${e.message}`, [
1181
+ 'Use what_look for text-based visual info without an image.',
1182
+ 'Use what_dom_inspect for HTML structure.',
1183
+ ]);
1184
+ }
1185
+ }
1186
+ );
789
1187
  }
package/src/tools.js CHANGED
@@ -52,7 +52,7 @@ export function registerTools(server, bridge) {
52
52
 
53
53
  server.tool(
54
54
  'what_connection_status',
55
- 'Check if a What Framework app is connected via WebSocket',
55
+ 'Bootstrap endpoint: check connection, get app info, see available tools and recommended workflow',
56
56
  {},
57
57
  async () => {
58
58
  const connected = bridge.isConnected();
@@ -61,31 +61,77 @@ export function registerTools(server, bridge) {
61
61
  const effectCount = snapshot?.effects?.length || 0;
62
62
  const componentCount = snapshot?.components?.length || 0;
63
63
 
64
+ // Try to get app metadata from the browser
65
+ let appInfo = null;
66
+ if (connected) {
67
+ try {
68
+ appInfo = await bridge.sendCommand('get-app-info');
69
+ // If the client doesn't support this command, appInfo may be null or have an error
70
+ if (appInfo?.error) appInfo = null;
71
+ } catch {
72
+ // Old client without get-app-info support — skip gracefully
73
+ appInfo = null;
74
+ }
75
+ }
76
+
64
77
  let summary;
65
78
  if (!connected) {
66
79
  summary = 'No browser connected. Start your app with the what-devtools-mcp Vite plugin and refresh the page.';
67
80
  } else if (!snapshot) {
68
81
  summary = 'Browser connected but no snapshot received yet. Try refreshing the page.';
69
82
  } else {
70
- summary = `Connected. App has ${signalCount} signals, ${effectCount} effects, ${componentCount} components.`;
83
+ summary = `Connected to ${appInfo?.title || 'app'} at ${appInfo?.url || 'unknown URL'}. ${signalCount} signals, ${effectCount} effects, ${componentCount} components.`;
71
84
  }
72
85
 
73
86
  const result = {
74
87
  summary,
75
88
  connected,
76
89
  hasSnapshot: snapshot !== null,
90
+ // App info (from browser)
91
+ app: appInfo ? {
92
+ url: appInfo.url,
93
+ title: appInfo.title,
94
+ viewport: appInfo.viewport,
95
+ version: appInfo.version,
96
+ entryPoint: appInfo.entryPoint,
97
+ } : null,
98
+ // Counts
77
99
  signalCount,
78
100
  effectCount,
79
101
  componentCount,
80
- };
81
-
82
- if (!connected) {
83
- result.nextSteps = [
102
+ // Framework primer for agents that don't know WhatFW
103
+ framework: 'What Framework: signal-based reactivity. Components run ONCE (not like React). signal(val) for state — read with sig(), write with sig(newVal). effect() for side effects. computed() for derived values. Import from "what-framework".',
104
+ // Recommended next steps
105
+ workflow: connected ? [
106
+ 'what_components — see component tree and IDs',
107
+ 'what_signals {filter: "yourSignalName"} — check specific state (always filter!)',
108
+ 'what_diagnose — one-call health check',
109
+ 'what_look {componentId: N} — visual info without screenshot',
110
+ 'what_errors — check for runtime errors',
111
+ ] : [
84
112
  'Make sure your app is running with the what-devtools-mcp Vite plugin',
85
- 'Check that the MCP bridge server is running (npx what-devtools-mcp)',
86
- 'Try refreshing the browser page',
87
- ];
88
- }
113
+ 'Or manually call connectDevToolsMCP() in your browser console',
114
+ ],
115
+ // Tool catalog so agents know what's available
116
+ tools: [
117
+ { name: 'what_components', desc: 'List mounted components with IDs' },
118
+ { name: 'what_signals', desc: 'List signals with values (use filter!)' },
119
+ { name: 'what_effects', desc: 'List effects with deps and run counts' },
120
+ { name: 'what_explain', desc: 'Everything about one component (signals + effects + DOM + errors)' },
121
+ { name: 'what_look', desc: 'Visual info without image: styles, layout, dimensions' },
122
+ { name: 'what_screenshot', desc: 'Cropped component screenshot (5-20KB)' },
123
+ { name: 'what_page_map', desc: 'Full page layout skeleton' },
124
+ { name: 'what_diagnose', desc: 'One-call health check (errors + perf + reactivity)' },
125
+ { name: 'what_errors', desc: 'Runtime errors with fix suggestions' },
126
+ { name: 'what_signal_trace', desc: 'Why did a signal change? Causal chain.' },
127
+ { name: 'what_dependency_graph', desc: 'Reactive dependency graph' },
128
+ { name: 'what_watch', desc: 'Observe events over a time window' },
129
+ { name: 'what_set_signal', desc: 'Change a signal value in the live app' },
130
+ { name: 'what_lint', desc: 'Static analysis for code (no browser needed)' },
131
+ { name: 'what_scaffold', desc: 'Generate boilerplate (no browser needed)' },
132
+ { name: 'what_fix', desc: 'Error diagnosis with code examples (no browser needed)' },
133
+ ],
134
+ };
89
135
 
90
136
  return {
91
137
  content: [{
@@ -98,12 +144,14 @@ export function registerTools(server, bridge) {
98
144
 
99
145
  server.tool(
100
146
  'what_signals',
101
- 'List all reactive signals with current values. Filter by name regex or ID.',
147
+ 'List all reactive signals with current values. Filter by name regex or ID. Named signals are sorted first for relevance.',
102
148
  {
103
149
  filter: z.string().optional().describe('Regex to filter signal names (ignored if id is set)'),
104
150
  id: z.number().optional().describe('Get a specific signal by ID (takes precedence over filter)'),
151
+ limit: z.number().optional().default(20).describe('Max signals to return (default: 20, max: 100)'),
152
+ named_only: z.boolean().optional().default(false).describe('If true, only return signals with debug names (filters out anonymous internal signals)'),
105
153
  },
106
- async ({ filter, id }) => {
154
+ async ({ filter, id, limit, named_only }) => {
107
155
  if (!bridge.isConnected()) {
108
156
  return noConnection('what_signals');
109
157
  }
@@ -124,6 +172,40 @@ export function registerTools(server, bridge) {
124
172
  }
125
173
  }
126
174
 
175
+ // Sort: named signals first (more useful), then by ID
176
+ signals.sort((a, b) => {
177
+ const aHasName = a.name && !a.name.startsWith('signal_');
178
+ const bHasName = b.name && !b.name.startsWith('signal_');
179
+ if (aHasName && !bHasName) return -1;
180
+ if (!aHasName && bHasName) return 1;
181
+ return a.id - b.id;
182
+ });
183
+
184
+ // Filter to named-only if requested
185
+ if (named_only) {
186
+ signals = signals.filter(s => s.name && !s.name.startsWith('signal_') && !s.name.startsWith('effect_'));
187
+ }
188
+
189
+ // Clean up circular references in values
190
+ signals = signals.map(s => {
191
+ const val = s.value;
192
+ if (val === '[Circular]' || (typeof val === 'string' && val.includes('[Circular]'))) {
193
+ return { ...s, value: '[ref]', _circular: true };
194
+ }
195
+ // Truncate large array/object values
196
+ if (typeof val === 'object' && val !== null) {
197
+ const str = JSON.stringify(val);
198
+ if (str && str.length > 200) {
199
+ return { ...s, value: str.substring(0, 197) + '...', _truncated: true };
200
+ }
201
+ }
202
+ return s;
203
+ });
204
+
205
+ // Apply limit AFTER sorting and filtering
206
+ const totalBeforeLimit = signals.length;
207
+ signals = signals.slice(0, Math.min(limit || 20, 100));
208
+
127
209
  // Build summary
128
210
  const valuePreviews = signals.slice(0, 5).map(s => {
129
211
  const val = typeof s.value === 'string' ? `'${s.value}'` : JSON.stringify(s.value);
@@ -133,7 +215,8 @@ export function registerTools(server, bridge) {
133
215
  const filterNote = id != null ? ` 1 matched id=${id}.` : filter ? ` ${signals.length} match filter '${filter}'.` : '';
134
216
  const valuesNote = valuePreviews.length > 0 ? ` Values: ${valuePreviews.join(', ')}` : '';
135
217
  const moreNote = signals.length > 5 ? `, ... (${signals.length - 5} more)` : '';
136
- const summary = `${totalCount} signals total.${filterNote}${valuesNote}${moreNote}`;
218
+ const limitNote = totalBeforeLimit > signals.length ? ` Showing ${signals.length} of ${totalBeforeLimit} (use limit param for more).` : '';
219
+ const summary = `${totalCount} signals total.${filterNote}${valuesNote}${moreNote}${limitNote}`;
137
220
 
138
221
  return {
139
222
  content: [{
@@ -225,7 +308,12 @@ export function registerTools(server, bridge) {
225
308
 
226
309
  // Build tree summary
227
310
  const { tree, depth } = buildComponentTreeSummary(components);
228
- const summary = `${totalCount} components mounted. Tree depth: ${depth}. Root: ${tree}`;
311
+
312
+ // Add source file hint based on component names
313
+ const sourceHint = 'Component source files are typically in the same directory as the app entry point. Use file search to find: ' +
314
+ components.slice(0, 5).map(c => c.name).filter(Boolean).join(', ');
315
+
316
+ const summary = `${totalCount} components mounted. Tree depth: ${depth}. Root: ${tree}. ${sourceHint}`;
229
317
 
230
318
  return {
231
319
  content: [{
@@ -233,6 +321,7 @@ export function registerTools(server, bridge) {
233
321
  text: JSON.stringify({
234
322
  summary,
235
323
  count: components.length,
324
+ sourceHint,
236
325
  components,
237
326
  }, null, 2),
238
327
  }],