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.
- package/package.json +3 -3
- package/src/bridge.js +66 -2
- package/src/client-commands.js +1636 -7
- package/src/client.js +53 -12
- package/src/index.js +478 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +426 -10
- package/src/tools-interact.js +420 -0
- package/src/tools.js +110 -14
- package/src/vite-plugin.js +44 -6
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);
|
|
@@ -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
|
}
|