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.
- package/package.json +2 -2
- package/src/bridge.js +66 -2
- package/src/client-commands.js +604 -5
- package/src/client.js +53 -12
- package/src/index.js +450 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +408 -10
- package/src/tools.js +103 -14
- package/src/vite-plugin.js +32 -2
package/src/tools-extended.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extended MCP tool definitions for what-devtools-mcp.
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
'
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}],
|