mcp-web-inspector 0.10.0 → 0.12.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/README.md
CHANGED
|
@@ -962,10 +962,10 @@ Drag an element to a target location
|
|
|
962
962
|
- targetSelector (string, required): CSS selector for the target location
|
|
963
963
|
|
|
964
964
|
#### `fill`
|
|
965
|
-
fill
|
|
965
|
+
fill an input/textarea/contenteditable; if the selector matches a wrapper, descends up to 4 levels to a unique fillable descendant (errors if zero or multiple)
|
|
966
966
|
|
|
967
967
|
- Parameters:
|
|
968
|
-
- selector (string, required): CSS selector for input field
|
|
968
|
+
- selector (string, required): CSS selector for input field or its wrapper
|
|
969
969
|
- value (string, required): Value to fill
|
|
970
970
|
|
|
971
971
|
#### `hover`
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { setSessionConfig } from "./toolHandler.js";
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
+
import { createServer } from "node:net";
|
|
11
12
|
// Get package.json version
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const PACKAGE_ROOT = join(__dirname, "..");
|
|
@@ -37,6 +38,9 @@ const { values } = parseArgs({
|
|
|
37
38
|
type: 'boolean',
|
|
38
39
|
default: false,
|
|
39
40
|
},
|
|
41
|
+
'cdp-port': {
|
|
42
|
+
type: 'string',
|
|
43
|
+
},
|
|
40
44
|
'print-tools-json': {
|
|
41
45
|
type: 'boolean',
|
|
42
46
|
default: false,
|
|
@@ -48,17 +52,49 @@ const { values } = parseArgs({
|
|
|
48
52
|
},
|
|
49
53
|
strict: false,
|
|
50
54
|
});
|
|
55
|
+
// Probe localhost:port; resolve true if free, false if in use.
|
|
56
|
+
function isPortFree(port) {
|
|
57
|
+
return new Promise(resolve => {
|
|
58
|
+
const srv = createServer();
|
|
59
|
+
srv.once('error', () => resolve(false));
|
|
60
|
+
srv.once('listening', () => srv.close(() => resolve(true)));
|
|
61
|
+
srv.listen(port, '127.0.0.1');
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// First free port in [start, start+span). Throws if none.
|
|
65
|
+
async function findFreePort(start, span) {
|
|
66
|
+
for (let p = start; p < start + span; p++) {
|
|
67
|
+
if (await isPortFree(p))
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`No free CDP port in ${start}..${start + span - 1}`);
|
|
71
|
+
}
|
|
72
|
+
// Resolve --cdp-port: 0 disables; explicit value used as-is; unset auto-picks from 9222 upward.
|
|
73
|
+
async function resolveCdpPort(raw) {
|
|
74
|
+
if (raw === undefined)
|
|
75
|
+
return findFreePort(9222, 100);
|
|
76
|
+
const n = Number.parseInt(raw, 10);
|
|
77
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
78
|
+
console.error(`Invalid --cdp-port value: ${raw}. Must be an integer in 0..65535 (0 disables).`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
51
83
|
// Configure session settings (session saving is enabled by default)
|
|
52
84
|
const baseDir = String(values['user-data-dir'] || './.mcp-web-inspector');
|
|
53
|
-
const sessionConfig = {
|
|
54
|
-
saveSession: !Boolean(values['no-save-session']),
|
|
55
|
-
userDataDir: `${baseDir}/user-data`,
|
|
56
|
-
screenshotsDir: `${baseDir}/screenshots`,
|
|
57
|
-
headlessDefault: Boolean(values['headless']) || (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY),
|
|
58
|
-
exposeSensitiveNetworkData: Boolean(values['expose-sensitive-network-data']),
|
|
59
|
-
};
|
|
60
|
-
setSessionConfig(sessionConfig);
|
|
61
85
|
async function runServer() {
|
|
86
|
+
// Skip port resolution when only printing metadata — no browser will launch.
|
|
87
|
+
const printOnly = Boolean(values['print-tools-json'] || values['print-tools-md']);
|
|
88
|
+
const cdpPort = printOnly ? 0 : await resolveCdpPort(values['cdp-port']);
|
|
89
|
+
const sessionConfig = {
|
|
90
|
+
saveSession: !Boolean(values['no-save-session']),
|
|
91
|
+
userDataDir: `${baseDir}/user-data`,
|
|
92
|
+
screenshotsDir: `${baseDir}/screenshots`,
|
|
93
|
+
headlessDefault: Boolean(values['headless']) || (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY),
|
|
94
|
+
exposeSensitiveNetworkData: Boolean(values['expose-sensitive-network-data']),
|
|
95
|
+
cdpPort,
|
|
96
|
+
};
|
|
97
|
+
setSessionConfig(sessionConfig);
|
|
62
98
|
// Create tool definitions with session config
|
|
63
99
|
const TOOLS = createToolDefinitions(sessionConfig);
|
|
64
100
|
// CLI utilities: print tools metadata (JSON/Markdown) and exit
|
|
@@ -72,6 +108,9 @@ async function runServer() {
|
|
|
72
108
|
return;
|
|
73
109
|
}
|
|
74
110
|
console.error(`Starting mcp-web-inspector v${VERSION}`);
|
|
111
|
+
const cdpInstruction = sessionConfig.cdpPort > 0
|
|
112
|
+
? `External Playwright clients can attach to this browser via Chrome DevTools Protocol at http://localhost:${sessionConfig.cdpPort} — pass that URL to chromium.connectOverCDP() to share cookies, localStorage, and the open page set with this server.`
|
|
113
|
+
: undefined;
|
|
75
114
|
const server = new Server({
|
|
76
115
|
name: "mcp-web-inspector",
|
|
77
116
|
version: VERSION,
|
|
@@ -80,6 +119,7 @@ async function runServer() {
|
|
|
80
119
|
resources: {},
|
|
81
120
|
tools: {},
|
|
82
121
|
},
|
|
122
|
+
...(cdpInstruction ? { instructions: cdpInstruction } : {}),
|
|
83
123
|
});
|
|
84
124
|
// Setup request handlers
|
|
85
125
|
setupRequestHandlers(server, TOOLS);
|
package/dist/toolHandler.js
CHANGED
|
@@ -13,6 +13,7 @@ let sessionConfig = {
|
|
|
13
13
|
screenshotsDir: './.mcp-web-inspector/screenshots',
|
|
14
14
|
headlessDefault: false,
|
|
15
15
|
exposeSensitiveNetworkData: false,
|
|
16
|
+
cdpPort: 0,
|
|
16
17
|
};
|
|
17
18
|
let colorSchemeOverride = null;
|
|
18
19
|
/**
|
|
@@ -400,10 +401,14 @@ export async function ensureBrowser(browserSettings) {
|
|
|
400
401
|
// IPs (e.g. Tailscale 100.64.0.0/10). This breaks environments where the API is on an
|
|
401
402
|
// internal network but the app is served from a public CDN.
|
|
402
403
|
// Prepare context options
|
|
404
|
+
const launchArgs = ['--disable-features=LocalNetworkAccessChecks'];
|
|
405
|
+
if (sessionConfig.cdpPort && sessionConfig.cdpPort > 0) {
|
|
406
|
+
launchArgs.push(`--remote-debugging-port=${sessionConfig.cdpPort}`);
|
|
407
|
+
}
|
|
403
408
|
const contextOptions = {
|
|
404
409
|
headless,
|
|
405
410
|
executablePath: executablePath,
|
|
406
|
-
args:
|
|
411
|
+
args: launchArgs,
|
|
407
412
|
};
|
|
408
413
|
// If device config exists, use it; otherwise use manual viewport/userAgent
|
|
409
414
|
if (deviceConfig) {
|
|
@@ -439,7 +444,10 @@ export async function ensureBrowser(browserSettings) {
|
|
|
439
444
|
else {
|
|
440
445
|
browser = await browserInstance.launch({
|
|
441
446
|
headless,
|
|
442
|
-
executablePath: executablePath
|
|
447
|
+
executablePath: executablePath,
|
|
448
|
+
args: sessionConfig.cdpPort && sessionConfig.cdpPort > 0
|
|
449
|
+
? [`--remote-debugging-port=${sessionConfig.cdpPort}`]
|
|
450
|
+
: [],
|
|
443
451
|
});
|
|
444
452
|
currentBrowserType = browserType;
|
|
445
453
|
// Add cleanup logic when browser is disconnected
|
|
@@ -608,10 +616,14 @@ export async function ensureBrowser(browserSettings) {
|
|
|
608
616
|
retryViewportHeight = screenSize?.height ?? 720;
|
|
609
617
|
}
|
|
610
618
|
// Prepare context options
|
|
619
|
+
const retryLaunchArgs = ['--disable-features=LocalNetworkAccessChecks'];
|
|
620
|
+
if (sessionConfig.cdpPort && sessionConfig.cdpPort > 0) {
|
|
621
|
+
retryLaunchArgs.push(`--remote-debugging-port=${sessionConfig.cdpPort}`);
|
|
622
|
+
}
|
|
611
623
|
const retryContextOptions = {
|
|
612
624
|
headless,
|
|
613
625
|
executablePath: executablePath,
|
|
614
|
-
args:
|
|
626
|
+
args: retryLaunchArgs,
|
|
615
627
|
};
|
|
616
628
|
// If device config exists, use it; otherwise use manual viewport/userAgent
|
|
617
629
|
if (deviceConfig) {
|
|
@@ -644,7 +656,10 @@ export async function ensureBrowser(browserSettings) {
|
|
|
644
656
|
else {
|
|
645
657
|
browser = await browserInstance.launch({
|
|
646
658
|
headless,
|
|
647
|
-
executablePath: executablePath
|
|
659
|
+
executablePath: executablePath,
|
|
660
|
+
args: sessionConfig.cdpPort && sessionConfig.cdpPort > 0
|
|
661
|
+
? [`--remote-debugging-port=${sessionConfig.cdpPort}`]
|
|
662
|
+
: [],
|
|
648
663
|
});
|
|
649
664
|
currentBrowserType = browserType;
|
|
650
665
|
browser.on('disconnected', () => {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { BrowserToolBase } from '../base.js';
|
|
2
2
|
import { ToolContext, ToolResponse, ToolMetadata, SessionConfig } from '../../common/types.js';
|
|
3
|
-
|
|
4
|
-
* Tool for filling form fields
|
|
5
|
-
*/
|
|
3
|
+
export declare const BOUNDED_FILLABLE_DESCENDANT_SELECTOR: string;
|
|
6
4
|
export declare class FillTool extends BrowserToolBase {
|
|
7
5
|
static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
|
|
8
6
|
execute(args: any, context: ToolContext): Promise<ToolResponse>;
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { BrowserToolBase } from '../base.js';
|
|
2
2
|
import { ANNOTATIONS, createSuccessResponse } from '../../common/types.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const FILLABLE_BASE_SELECTOR = 'input:not([type="button"]):not([type="submit"]):not([type="reset"])' +
|
|
4
|
+
':not([type="checkbox"]):not([type="radio"]):not([type="file"])' +
|
|
5
|
+
':not([type="image"]):not([type="hidden"]),' +
|
|
6
|
+
'textarea,' +
|
|
7
|
+
'[contenteditable=""],' +
|
|
8
|
+
'[contenteditable="true"]';
|
|
9
|
+
const MAX_DESCENT_DEPTH = 4;
|
|
10
|
+
export const BOUNDED_FILLABLE_DESCENDANT_SELECTOR = (() => {
|
|
11
|
+
const parts = FILLABLE_BASE_SELECTOR.split(',').map((s) => s.trim());
|
|
12
|
+
const groups = [];
|
|
13
|
+
for (let depth = 1; depth <= MAX_DESCENT_DEPTH; depth++) {
|
|
14
|
+
const ancestors = '* > '.repeat(depth - 1);
|
|
15
|
+
for (const p of parts) {
|
|
16
|
+
groups.push(`:scope > ${ancestors}${p}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return groups.join(', ');
|
|
20
|
+
})();
|
|
6
21
|
export class FillTool extends BrowserToolBase {
|
|
7
22
|
static getMetadata(sessionConfig) {
|
|
8
23
|
return {
|
|
9
24
|
name: "fill",
|
|
10
|
-
description: "fill
|
|
25
|
+
description: "fill an input/textarea/contenteditable; if the selector matches a wrapper, descends up to 4 levels to a unique fillable descendant (errors if zero or multiple)",
|
|
11
26
|
annotations: ANNOTATIONS.interaction,
|
|
12
27
|
inputSchema: {
|
|
13
28
|
type: "object",
|
|
14
29
|
properties: {
|
|
15
|
-
selector: { type: "string", description: "CSS selector for input field" },
|
|
30
|
+
selector: { type: "string", description: "CSS selector for input field or its wrapper" },
|
|
16
31
|
value: { type: "string", description: "Value to fill" },
|
|
17
32
|
},
|
|
18
33
|
required: ["selector", "value"],
|
|
@@ -23,14 +38,97 @@ export class FillTool extends BrowserToolBase {
|
|
|
23
38
|
this.recordInteraction();
|
|
24
39
|
return this.safeExecute(context, async (page) => {
|
|
25
40
|
const normalizedSelector = this.normalizeSelector(args.selector);
|
|
26
|
-
// Use standard element selection with error on multiple matches
|
|
27
41
|
const locator = page.locator(normalizedSelector);
|
|
28
42
|
const { element } = await this.selectPreferredLocator(locator, {
|
|
29
43
|
errorOnMultiple: true,
|
|
30
44
|
originalSelector: args.selector,
|
|
31
45
|
});
|
|
32
|
-
await element.
|
|
33
|
-
|
|
46
|
+
const wrapperInfo = await element.evaluate((el) => {
|
|
47
|
+
const fillableMatch = (node) => {
|
|
48
|
+
if (!node || node.nodeType !== 1)
|
|
49
|
+
return false;
|
|
50
|
+
const tag = (node.tagName || '').toLowerCase();
|
|
51
|
+
if (tag === 'textarea')
|
|
52
|
+
return true;
|
|
53
|
+
if (tag === 'input') {
|
|
54
|
+
const type = (node.getAttribute('type') || '').toLowerCase();
|
|
55
|
+
return !['button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'hidden'].includes(type);
|
|
56
|
+
}
|
|
57
|
+
const ce = node.getAttribute('contenteditable');
|
|
58
|
+
return ce === '' || ce === 'true';
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
isFillable: fillableMatch(el),
|
|
62
|
+
tag: (el.tagName || '').toLowerCase(),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
if (wrapperInfo.isFillable) {
|
|
66
|
+
await element.fill(args.value);
|
|
67
|
+
return createSuccessResponse(`Filled ${args.selector} with: ${args.value}`);
|
|
68
|
+
}
|
|
69
|
+
const descendants = element.locator(BOUNDED_FILLABLE_DESCENDANT_SELECTOR);
|
|
70
|
+
const count = await descendants.count();
|
|
71
|
+
if (count === 0) {
|
|
72
|
+
throw new Error(`Selector "${args.selector}" matched a <${wrapperInfo.tag}> wrapper with no fillable descendants within ${MAX_DESCENT_DEPTH} levels (input, textarea, contenteditable).`);
|
|
73
|
+
}
|
|
74
|
+
if (count > 1) {
|
|
75
|
+
const candidates = await describeFillableCandidates(descendants, count);
|
|
76
|
+
throw new Error(`Selector "${args.selector}" matched a <${wrapperInfo.tag}> wrapper with ${count} fillable descendants. ` +
|
|
77
|
+
`Use a more specific selector or add data-testid to the input itself.\n\nCandidates:\n${candidates}`);
|
|
78
|
+
}
|
|
79
|
+
const target = descendants.first();
|
|
80
|
+
const targetTag = await target.evaluate((el) => {
|
|
81
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
82
|
+
const type = el.getAttribute?.('type');
|
|
83
|
+
return type ? `<${tag} type="${type}">` : `<${tag}>`;
|
|
84
|
+
});
|
|
85
|
+
await target.fill(args.value);
|
|
86
|
+
return createSuccessResponse(`Filled ${args.selector} with: ${args.value} (descended into ${targetTag})`);
|
|
34
87
|
});
|
|
35
88
|
}
|
|
36
89
|
}
|
|
90
|
+
async function describeFillableCandidates(locator, count) {
|
|
91
|
+
const max = Math.min(count, 5);
|
|
92
|
+
const lines = [];
|
|
93
|
+
for (let i = 0; i < max; i++) {
|
|
94
|
+
const nth = locator.nth(i);
|
|
95
|
+
try {
|
|
96
|
+
const info = await nth.evaluate((el) => {
|
|
97
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
98
|
+
const type = el.getAttribute?.('type') || null;
|
|
99
|
+
const name = el.getAttribute?.('name') || null;
|
|
100
|
+
const id = el.id || null;
|
|
101
|
+
const placeholder = el.getAttribute?.('placeholder') || null;
|
|
102
|
+
const ariaLabel = el.getAttribute?.('aria-label') || null;
|
|
103
|
+
const testid = el.getAttribute?.('data-testid') || el.getAttribute?.('data-test') || el.getAttribute?.('data-cy') || null;
|
|
104
|
+
return { tag, type, name, id, placeholder, ariaLabel, testid };
|
|
105
|
+
});
|
|
106
|
+
const attrs = [];
|
|
107
|
+
if (info.type)
|
|
108
|
+
attrs.push(`type="${info.type}"`);
|
|
109
|
+
if (info.name)
|
|
110
|
+
attrs.push(`name="${info.name}"`);
|
|
111
|
+
if (info.id)
|
|
112
|
+
attrs.push(`id="${info.id}"`);
|
|
113
|
+
if (info.placeholder)
|
|
114
|
+
attrs.push(`placeholder="${truncate(info.placeholder)}"`);
|
|
115
|
+
if (info.ariaLabel)
|
|
116
|
+
attrs.push(`aria-label="${truncate(info.ariaLabel)}"`);
|
|
117
|
+
if (info.testid)
|
|
118
|
+
attrs.push(`data-testid="${info.testid}"`);
|
|
119
|
+
const head = `[${i}] <${info.tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`;
|
|
120
|
+
const suggestion = info.testid ? `testid:${info.testid}` : info.id ? `id=${info.id}` : null;
|
|
121
|
+
lines.push(suggestion ? `${head}\n selector: ${suggestion}` : head);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
lines.push(`[${i}] (element)`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (count > max) {
|
|
128
|
+
lines.push(`… and ${count - max} more.`);
|
|
129
|
+
}
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
132
|
+
function truncate(s) {
|
|
133
|
+
return s.length > 60 ? `${s.slice(0, 57)}...` : s;
|
|
134
|
+
}
|