@wong2kim/wmux 1.1.1 → 1.1.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/README.md +14 -4
- package/dist/cli/cli/commands/browser.js +101 -77
- package/dist/cli/cli/index.js +6 -6
- package/dist/cli/shared/constants.js +3 -0
- package/dist/cli/shared/rpc.js +15 -4
- package/dist/mcp/mcp/index.js +41 -21
- package/dist/mcp/mcp/playwright/PlaywrightEngine.js +186 -0
- package/dist/mcp/mcp/playwright/anti-detection.js +58 -0
- package/dist/mcp/mcp/playwright/dom-intelligence.js +171 -0
- package/dist/mcp/mcp/playwright/human-typing.js +48 -0
- package/dist/mcp/mcp/playwright/markdown-extractor.js +520 -0
- package/dist/mcp/mcp/playwright/snapshot.js +261 -0
- package/dist/mcp/mcp/playwright/tools/extraction.js +143 -0
- package/dist/mcp/mcp/playwright/tools/file.js +274 -0
- package/dist/mcp/mcp/playwright/tools/inspection.js +395 -0
- package/dist/mcp/mcp/playwright/tools/interaction.js +387 -0
- package/dist/mcp/mcp/playwright/tools/navigation.js +183 -0
- package/dist/mcp/mcp/playwright/tools/state.js +410 -0
- package/dist/mcp/mcp/playwright/tools/utility.js +167 -0
- package/dist/mcp/mcp/playwright/tools/wait.js +111 -0
- package/dist/mcp/shared/constants.js +3 -0
- package/dist/mcp/shared/rpc.js +15 -4
- package/package.json +7 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateSnapshot = generateSnapshot;
|
|
4
|
+
exports.resolveRef = resolveRef;
|
|
5
|
+
// Roles considered interactive — these get a ref number in 'ai' format
|
|
6
|
+
const INTERACTIVE_ROLES = new Set([
|
|
7
|
+
'button',
|
|
8
|
+
'link',
|
|
9
|
+
'textbox',
|
|
10
|
+
'checkbox',
|
|
11
|
+
'radio',
|
|
12
|
+
'combobox',
|
|
13
|
+
'listbox',
|
|
14
|
+
'menuitem',
|
|
15
|
+
'menuitemcheckbox',
|
|
16
|
+
'menuitemradio',
|
|
17
|
+
'option',
|
|
18
|
+
'searchbox',
|
|
19
|
+
'slider',
|
|
20
|
+
'spinbutton',
|
|
21
|
+
'switch',
|
|
22
|
+
'tab',
|
|
23
|
+
'treeitem',
|
|
24
|
+
]);
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// CDP → AXNode tree builder
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
function buildTree(nodes) {
|
|
29
|
+
if (nodes.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const map = new Map();
|
|
32
|
+
for (const n of nodes)
|
|
33
|
+
map.set(n.nodeId, n);
|
|
34
|
+
function convert(cdp) {
|
|
35
|
+
if (cdp.ignored)
|
|
36
|
+
return null;
|
|
37
|
+
const role = cdp.role?.value ?? 'none';
|
|
38
|
+
const name = cdp.name?.value ?? '';
|
|
39
|
+
const node = { role, name };
|
|
40
|
+
if (cdp.value?.value)
|
|
41
|
+
node.value = cdp.value.value;
|
|
42
|
+
if (cdp.description?.value)
|
|
43
|
+
node.description = cdp.description.value;
|
|
44
|
+
if (cdp.backendDOMNodeId !== undefined)
|
|
45
|
+
node.backendDOMNodeId = cdp.backendDOMNodeId;
|
|
46
|
+
// Extract boolean/enum properties
|
|
47
|
+
if (cdp.properties) {
|
|
48
|
+
for (const prop of cdp.properties) {
|
|
49
|
+
switch (prop.name) {
|
|
50
|
+
case 'checked':
|
|
51
|
+
node.checked = prop.value.value === 'mixed' ? 'mixed' : !!prop.value.value;
|
|
52
|
+
break;
|
|
53
|
+
case 'disabled':
|
|
54
|
+
node.disabled = !!prop.value.value;
|
|
55
|
+
break;
|
|
56
|
+
case 'expanded':
|
|
57
|
+
node.expanded = !!prop.value.value;
|
|
58
|
+
break;
|
|
59
|
+
case 'focused':
|
|
60
|
+
node.focused = !!prop.value.value;
|
|
61
|
+
break;
|
|
62
|
+
case 'level':
|
|
63
|
+
node.level = Number(prop.value.value);
|
|
64
|
+
break;
|
|
65
|
+
case 'selected':
|
|
66
|
+
node.selected = !!prop.value.value;
|
|
67
|
+
break;
|
|
68
|
+
case 'pressed':
|
|
69
|
+
node.pressed = prop.value.value === 'mixed' ? 'mixed' : !!prop.value.value;
|
|
70
|
+
break;
|
|
71
|
+
case 'valuetext':
|
|
72
|
+
node.valuetext = String(prop.value.value);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Build children
|
|
78
|
+
if (cdp.childIds && cdp.childIds.length > 0) {
|
|
79
|
+
const children = [];
|
|
80
|
+
for (const cid of cdp.childIds) {
|
|
81
|
+
const child = map.get(cid);
|
|
82
|
+
if (child) {
|
|
83
|
+
const converted = convert(child);
|
|
84
|
+
if (converted)
|
|
85
|
+
children.push(converted);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (children.length > 0)
|
|
89
|
+
node.children = children;
|
|
90
|
+
}
|
|
91
|
+
return node;
|
|
92
|
+
}
|
|
93
|
+
return convert(nodes[0]);
|
|
94
|
+
}
|
|
95
|
+
/** Per-page storage of the last generated refMap to avoid concurrency issues */
|
|
96
|
+
const pageRefMaps = new WeakMap();
|
|
97
|
+
function isInteractive(role) {
|
|
98
|
+
return INTERACTIVE_ROLES.has(role);
|
|
99
|
+
}
|
|
100
|
+
function serializeNode(node, format, currentDepth, maxDepth, indent, refs) {
|
|
101
|
+
if (currentDepth > maxDepth)
|
|
102
|
+
return '';
|
|
103
|
+
const pad = ' '.repeat(indent);
|
|
104
|
+
const role = node.role;
|
|
105
|
+
const name = node.name || '';
|
|
106
|
+
// Build attribute string
|
|
107
|
+
const attrs = [];
|
|
108
|
+
if (format === 'ai' && isInteractive(role)) {
|
|
109
|
+
const ref = refs.length;
|
|
110
|
+
refs.push({ role, name, backendDOMNodeId: node.backendDOMNodeId });
|
|
111
|
+
attrs.push(`ref="${ref}"`);
|
|
112
|
+
}
|
|
113
|
+
if (node.checked !== undefined)
|
|
114
|
+
attrs.push(`checked="${node.checked}"`);
|
|
115
|
+
if (node.disabled)
|
|
116
|
+
attrs.push('disabled');
|
|
117
|
+
if (node.expanded !== undefined)
|
|
118
|
+
attrs.push(`expanded="${node.expanded}"`);
|
|
119
|
+
if (node.selected)
|
|
120
|
+
attrs.push('selected');
|
|
121
|
+
if (node.level !== undefined)
|
|
122
|
+
attrs.push(`level="${node.level}"`);
|
|
123
|
+
if (node.valuetext)
|
|
124
|
+
attrs.push(`valuetext="${node.valuetext}"`);
|
|
125
|
+
if (node.value)
|
|
126
|
+
attrs.push(`value="${node.value}"`);
|
|
127
|
+
const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
|
|
128
|
+
const nameStr = name ? ` "${name}"` : '';
|
|
129
|
+
let line = `${pad}- ${role}${nameStr}${attrStr}`;
|
|
130
|
+
// Recurse into children
|
|
131
|
+
const childLines = [];
|
|
132
|
+
if (node.children) {
|
|
133
|
+
for (const child of node.children) {
|
|
134
|
+
const childStr = serializeNode(child, format, currentDepth + 1, maxDepth, indent + 1, refs);
|
|
135
|
+
if (childStr)
|
|
136
|
+
childLines.push(childStr);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (childLines.length > 0) {
|
|
140
|
+
line += '\n' + childLines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
return line;
|
|
143
|
+
}
|
|
144
|
+
function serializeTree(root, format, maxDepth, refs) {
|
|
145
|
+
const children = root.children ?? [root];
|
|
146
|
+
const lines = [];
|
|
147
|
+
for (const child of children) {
|
|
148
|
+
const s = serializeNode(child, format, 0, maxDepth, 0, refs);
|
|
149
|
+
if (s)
|
|
150
|
+
lines.push(s);
|
|
151
|
+
}
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
function stripNonInteractive(node) {
|
|
155
|
+
if (isInteractive(node.role))
|
|
156
|
+
return node;
|
|
157
|
+
if (!node.children)
|
|
158
|
+
return null;
|
|
159
|
+
const filtered = node.children
|
|
160
|
+
.map(stripNonInteractive)
|
|
161
|
+
.filter((c) => c !== null);
|
|
162
|
+
if (filtered.length === 0)
|
|
163
|
+
return null;
|
|
164
|
+
return { ...node, children: filtered };
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// CDP helpers
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
async function getAccessibilityTree(page) {
|
|
170
|
+
const client = await page.context().newCDPSession(page);
|
|
171
|
+
try {
|
|
172
|
+
const { nodes } = await client.send('Accessibility.getFullAXTree');
|
|
173
|
+
return buildTree(nodes);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await client.detach().catch(() => { });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Public API
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Generate an accessibility-tree snapshot of the page.
|
|
184
|
+
*
|
|
185
|
+
* In 'ai' format every interactive element receives a sequential `ref="N"`
|
|
186
|
+
* attribute that can later be resolved back to an ElementHandle via
|
|
187
|
+
* `resolveRef()`.
|
|
188
|
+
*
|
|
189
|
+
* Uses CDP `Accessibility.getFullAXTree` under the hood to obtain a
|
|
190
|
+
* structured tree that can be filtered and annotated.
|
|
191
|
+
*/
|
|
192
|
+
async function generateSnapshot(page, options) {
|
|
193
|
+
const format = options?.format ?? 'ai';
|
|
194
|
+
const depth = options?.depth ?? 10;
|
|
195
|
+
const maxLength = options?.maxLength ?? 50000;
|
|
196
|
+
const tree = await getAccessibilityTree(page);
|
|
197
|
+
if (!tree) {
|
|
198
|
+
pageRefMaps.set(page, []);
|
|
199
|
+
return '(empty page)';
|
|
200
|
+
}
|
|
201
|
+
let refs = [];
|
|
202
|
+
let output = serializeTree(tree, format, depth, refs);
|
|
203
|
+
// If the output exceeds maxLength AND we are in 'ai' mode, strip
|
|
204
|
+
// non-interactive nodes and regenerate.
|
|
205
|
+
if (output.length > maxLength && format === 'ai') {
|
|
206
|
+
const trimmed = stripNonInteractive(tree);
|
|
207
|
+
if (trimmed) {
|
|
208
|
+
refs = [];
|
|
209
|
+
output = serializeTree(trimmed, format, depth, refs);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Hard-truncate as a last resort
|
|
213
|
+
if (output.length > maxLength) {
|
|
214
|
+
output = output.slice(0, maxLength) + '\n... (truncated)';
|
|
215
|
+
}
|
|
216
|
+
// Store the refMap for this page so resolveRef can use it without re-querying
|
|
217
|
+
pageRefMaps.set(page, refs);
|
|
218
|
+
return output;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a ref number (produced by `generateSnapshot` with format='ai')
|
|
222
|
+
* back to a live ElementHandle.
|
|
223
|
+
*
|
|
224
|
+
* Uses the refMap stored during the last `generateSnapshot()` call for
|
|
225
|
+
* the same page, avoiding a full accessibility tree re-query.
|
|
226
|
+
*
|
|
227
|
+
* Falls back to role-based locator matching using the stored role+name.
|
|
228
|
+
*/
|
|
229
|
+
async function resolveRef(page, ref) {
|
|
230
|
+
const targetIndex = parseInt(ref, 10);
|
|
231
|
+
if (Number.isNaN(targetIndex) || targetIndex < 0)
|
|
232
|
+
return null;
|
|
233
|
+
const refs = pageRefMaps.get(page);
|
|
234
|
+
if (!refs || targetIndex >= refs.length)
|
|
235
|
+
return null;
|
|
236
|
+
const target = refs[targetIndex];
|
|
237
|
+
// Use Playwright's getByRole to locate the element
|
|
238
|
+
try {
|
|
239
|
+
const locator = page.getByRole(target.role, {
|
|
240
|
+
name: target.name || undefined,
|
|
241
|
+
exact: true,
|
|
242
|
+
});
|
|
243
|
+
const count = await locator.count();
|
|
244
|
+
if (count === 0)
|
|
245
|
+
return null;
|
|
246
|
+
// Find which instance corresponds to our ref by counting all
|
|
247
|
+
// refs with the same role+name before our target index
|
|
248
|
+
let sameRoleNameBefore = 0;
|
|
249
|
+
for (let i = 0; i < targetIndex; i++) {
|
|
250
|
+
if (refs[i].role === target.role &&
|
|
251
|
+
refs[i].name === target.name) {
|
|
252
|
+
sameRoleNameBefore++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const nth = Math.min(sameRoleNameBefore, count - 1);
|
|
256
|
+
return await locator.nth(nth).elementHandle();
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerExtractionTools = registerExtractionTools;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const PlaywrightEngine_1 = require("../PlaywrightEngine");
|
|
6
|
+
const dom_intelligence_1 = require("../dom-intelligence");
|
|
7
|
+
const markdown_extractor_1 = require("../markdown-extractor");
|
|
8
|
+
// Optional surfaceId schema reused across tools
|
|
9
|
+
const optionalSurfaceId = zod_1.z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe('Target a specific surface by ID. Omit to use the active surface.');
|
|
13
|
+
/**
|
|
14
|
+
* Register extraction-related MCP tools on the given server.
|
|
15
|
+
*
|
|
16
|
+
* Tools:
|
|
17
|
+
* - browser_smart_snapshot -- smart snapshot with indexed interactive elements
|
|
18
|
+
* - browser_extract_text -- extract page content as clean markdown
|
|
19
|
+
* - browser_extract_data -- extract structured data as JSON
|
|
20
|
+
*/
|
|
21
|
+
function registerExtractionTools(server) {
|
|
22
|
+
const engine = PlaywrightEngine_1.PlaywrightEngine.getInstance();
|
|
23
|
+
// -----------------------------------------------------------------------
|
|
24
|
+
// browser_smart_snapshot
|
|
25
|
+
// -----------------------------------------------------------------------
|
|
26
|
+
server.tool('browser_smart_snapshot', 'Get a smart snapshot of the page with indexed interactive elements and clean text content. Use element ref numbers with browser_click to interact.', {
|
|
27
|
+
maxContentLength: zod_1.z
|
|
28
|
+
.number()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Maximum length of the content summary in characters (default 3000).'),
|
|
31
|
+
surfaceId: optionalSurfaceId,
|
|
32
|
+
}, async ({ maxContentLength, surfaceId }) => {
|
|
33
|
+
try {
|
|
34
|
+
const page = await engine.getPage(surfaceId);
|
|
35
|
+
if (!page) {
|
|
36
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
37
|
+
}
|
|
38
|
+
const snapshot = await (0, dom_intelligence_1.getSmartSnapshot)(page, {
|
|
39
|
+
maxContentLength: maxContentLength ?? 3000,
|
|
40
|
+
});
|
|
41
|
+
// Format the snapshot output: indexed elements + content summary
|
|
42
|
+
const lines = [];
|
|
43
|
+
lines.push(`Page: ${snapshot.title ?? page.url()}`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
if (snapshot.elements && snapshot.elements.length > 0) {
|
|
46
|
+
lines.push('Interactive Elements:');
|
|
47
|
+
for (const el of snapshot.elements) {
|
|
48
|
+
lines.push(` [${el.ref}] ${el.role} "${el.name}"${el.description ? ` - ${el.description}` : ''}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
if (snapshot.content) {
|
|
53
|
+
lines.push('Page Content:');
|
|
54
|
+
lines.push(snapshot.content);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text', text: message }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// -----------------------------------------------------------------------
|
|
69
|
+
// browser_extract_text
|
|
70
|
+
// -----------------------------------------------------------------------
|
|
71
|
+
server.tool('browser_extract_text', 'Extract page content as clean markdown text, stripping navigation and noise.', {
|
|
72
|
+
selector: zod_1.z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('CSS selector to scope extraction to a specific element.'),
|
|
76
|
+
maxLength: zod_1.z
|
|
77
|
+
.number()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe('Maximum length of the returned markdown in characters.'),
|
|
80
|
+
includeLinks: zod_1.z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('If true, preserve hyperlinks in the markdown output (default false).'),
|
|
84
|
+
surfaceId: optionalSurfaceId,
|
|
85
|
+
}, async ({ selector, maxLength, includeLinks, surfaceId }) => {
|
|
86
|
+
try {
|
|
87
|
+
const page = await engine.getPage(surfaceId);
|
|
88
|
+
if (!page) {
|
|
89
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
90
|
+
}
|
|
91
|
+
const markdown = await (0, markdown_extractor_1.extractMarkdown)(page, {
|
|
92
|
+
selector,
|
|
93
|
+
maxLength,
|
|
94
|
+
includeLinks,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: markdown }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text: message }],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
// browser_extract_data
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
server.tool('browser_extract_data', 'Extract structured data from the page (tables, lists, repeated items) as JSON.', {
|
|
112
|
+
goal: zod_1.z
|
|
113
|
+
.string()
|
|
114
|
+
.describe('Description of what data to extract (e.g. "product list", "search results").'),
|
|
115
|
+
fields: zod_1.z
|
|
116
|
+
.record(zod_1.z.string(), zod_1.z.string())
|
|
117
|
+
.describe('Map of field names to their expected types (e.g. { name: "string", price: "number", url: "string" }).'),
|
|
118
|
+
surfaceId: optionalSurfaceId,
|
|
119
|
+
}, async ({ goal, fields, surfaceId }) => {
|
|
120
|
+
try {
|
|
121
|
+
const page = await engine.getPage(surfaceId);
|
|
122
|
+
if (!page) {
|
|
123
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
124
|
+
}
|
|
125
|
+
const records = await (0, markdown_extractor_1.extractStructuredData)(page, goal, fields);
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: 'text',
|
|
130
|
+
text: JSON.stringify(records, null, 2),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
return {
|
|
138
|
+
content: [{ type: 'text', text: message }],
|
|
139
|
+
isError: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.registerFileTools = registerFileTools;
|
|
37
|
+
const zod_1 = require("zod");
|
|
38
|
+
const PlaywrightEngine_1 = require("../PlaywrightEngine");
|
|
39
|
+
const snapshot_1 = require("../snapshot");
|
|
40
|
+
// Optional surfaceId schema reused across tools
|
|
41
|
+
const optionalSurfaceId = zod_1.z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('Target a specific surface by ID. Omit to use the active surface.');
|
|
45
|
+
/**
|
|
46
|
+
* Register file-related MCP tools on the given server.
|
|
47
|
+
*
|
|
48
|
+
* Tools:
|
|
49
|
+
* - browser_file_upload — upload files to a file input
|
|
50
|
+
* - browser_download — click an element and capture the download
|
|
51
|
+
* - browser_wait_for_download — wait for a download event
|
|
52
|
+
* - browser_dialog — pre-register a dialog accept/dismiss handler
|
|
53
|
+
*/
|
|
54
|
+
function registerFileTools(server) {
|
|
55
|
+
const engine = PlaywrightEngine_1.PlaywrightEngine.getInstance();
|
|
56
|
+
// -----------------------------------------------------------------------
|
|
57
|
+
// browser_file_upload
|
|
58
|
+
// -----------------------------------------------------------------------
|
|
59
|
+
server.tool('browser_file_upload', 'Upload files to a file input element. Specify a ref to target a specific input, or omit to use the first file input on the page.', {
|
|
60
|
+
paths: zod_1.z
|
|
61
|
+
.array(zod_1.z.string())
|
|
62
|
+
.describe('Array of absolute file paths to upload.'),
|
|
63
|
+
ref: zod_1.z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('Ref number of the file input element (from browser_snapshot).'),
|
|
67
|
+
surfaceId: optionalSurfaceId,
|
|
68
|
+
}, async ({ paths, ref, surfaceId }) => {
|
|
69
|
+
try {
|
|
70
|
+
const page = await engine.getPage(surfaceId);
|
|
71
|
+
if (!page) {
|
|
72
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
73
|
+
}
|
|
74
|
+
if (ref) {
|
|
75
|
+
const el = await (0, snapshot_1.resolveRef)(page, ref);
|
|
76
|
+
if (!el) {
|
|
77
|
+
throw new Error(`Could not resolve ref="${ref}" to an element.`);
|
|
78
|
+
}
|
|
79
|
+
await el.setInputFiles(paths);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Find the first file input on the page
|
|
83
|
+
const fileInput = await page.$('input[type="file"]');
|
|
84
|
+
if (!fileInput) {
|
|
85
|
+
throw new Error('No file input element found on the page.');
|
|
86
|
+
}
|
|
87
|
+
await fileInput.setInputFiles(paths);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: `Uploaded ${paths.length} file(s)`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: 'text', text: message }],
|
|
102
|
+
isError: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// browser_download
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
server.tool('browser_download', 'Click an element (identified by ref) and capture the resulting download. Returns the downloaded file path.', {
|
|
110
|
+
ref: zod_1.z
|
|
111
|
+
.string()
|
|
112
|
+
.describe('Ref number of the element to click to trigger the download.'),
|
|
113
|
+
filename: zod_1.z
|
|
114
|
+
.string()
|
|
115
|
+
.optional()
|
|
116
|
+
.describe('Optional filename to save the download as.'),
|
|
117
|
+
surfaceId: optionalSurfaceId,
|
|
118
|
+
}, async ({ ref, filename, surfaceId }) => {
|
|
119
|
+
try {
|
|
120
|
+
const page = await engine.getPage(surfaceId);
|
|
121
|
+
if (!page) {
|
|
122
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
123
|
+
}
|
|
124
|
+
const el = await (0, snapshot_1.resolveRef)(page, ref);
|
|
125
|
+
if (!el) {
|
|
126
|
+
throw new Error(`Could not resolve ref="${ref}" to an element.`);
|
|
127
|
+
}
|
|
128
|
+
// Start waiting for download before clicking
|
|
129
|
+
const [download] = await Promise.all([
|
|
130
|
+
page.waitForEvent('download'),
|
|
131
|
+
el.click(),
|
|
132
|
+
]);
|
|
133
|
+
let filePath;
|
|
134
|
+
if (filename) {
|
|
135
|
+
const path = await Promise.resolve().then(() => __importStar(require('path')));
|
|
136
|
+
const os = await Promise.resolve().then(() => __importStar(require('os')));
|
|
137
|
+
const savePath = path.join(os.tmpdir(), filename);
|
|
138
|
+
await download.saveAs(savePath);
|
|
139
|
+
filePath = savePath;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const downloadPath = await download.path();
|
|
143
|
+
filePath = downloadPath ?? download.suggestedFilename();
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: 'text',
|
|
149
|
+
text: `Downloaded: ${filePath}`,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: 'text', text: message }],
|
|
158
|
+
isError: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// browser_wait_for_download
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
server.tool('browser_wait_for_download', 'Wait for a download event on the page. Optionally filter by filename.', {
|
|
166
|
+
filename: zod_1.z
|
|
167
|
+
.string()
|
|
168
|
+
.optional()
|
|
169
|
+
.describe('Expected filename to match against the download.'),
|
|
170
|
+
timeout: zod_1.z
|
|
171
|
+
.number()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe('Maximum wait time in milliseconds. Defaults to 30000.'),
|
|
174
|
+
surfaceId: optionalSurfaceId,
|
|
175
|
+
}, async ({ filename, timeout, surfaceId }) => {
|
|
176
|
+
const resolvedTimeout = timeout ?? 30000;
|
|
177
|
+
try {
|
|
178
|
+
const page = await engine.getPage(surfaceId);
|
|
179
|
+
if (!page) {
|
|
180
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
181
|
+
}
|
|
182
|
+
const download = await page.waitForEvent('download', {
|
|
183
|
+
timeout: resolvedTimeout,
|
|
184
|
+
});
|
|
185
|
+
const suggestedName = download.suggestedFilename();
|
|
186
|
+
if (filename && suggestedName !== filename) {
|
|
187
|
+
return {
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: 'text',
|
|
191
|
+
text: `Download received but filename mismatch: expected "${filename}", got "${suggestedName}"`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const downloadPath = await download.path();
|
|
198
|
+
return {
|
|
199
|
+
content: [
|
|
200
|
+
{
|
|
201
|
+
type: 'text',
|
|
202
|
+
text: JSON.stringify({
|
|
203
|
+
suggestedFilename: suggestedName,
|
|
204
|
+
url: download.url(),
|
|
205
|
+
path: downloadPath ?? '(pending)',
|
|
206
|
+
}, null, 2),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
213
|
+
if (message.includes('Timeout') || message.includes('timeout')) {
|
|
214
|
+
return {
|
|
215
|
+
content: [
|
|
216
|
+
{
|
|
217
|
+
type: 'text',
|
|
218
|
+
text: `Timed out after ${resolvedTimeout}ms waiting for download`,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
isError: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: 'text', text: message }],
|
|
226
|
+
isError: true,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// -----------------------------------------------------------------------
|
|
231
|
+
// browser_dialog
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
server.tool('browser_dialog', 'Pre-register a handler for the next browser dialog (alert, confirm, prompt, beforeunload). The handler will automatically accept or dismiss the dialog when it appears.', {
|
|
234
|
+
accept: zod_1.z
|
|
235
|
+
.boolean()
|
|
236
|
+
.describe('Whether to accept (true) or dismiss (false) the dialog.'),
|
|
237
|
+
text: zod_1.z
|
|
238
|
+
.string()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe('Text to enter in a prompt dialog before accepting.'),
|
|
241
|
+
surfaceId: optionalSurfaceId,
|
|
242
|
+
}, async ({ accept, text, surfaceId }) => {
|
|
243
|
+
try {
|
|
244
|
+
const page = await engine.getPage(surfaceId);
|
|
245
|
+
if (!page) {
|
|
246
|
+
throw new Error('No browser page available. Call browser_open first.');
|
|
247
|
+
}
|
|
248
|
+
page.once('dialog', async (dialog) => {
|
|
249
|
+
if (accept) {
|
|
250
|
+
await dialog.accept(text);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
await dialog.dismiss();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
const action = accept ? 'accepted' : 'dismissed';
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: 'text',
|
|
261
|
+
text: `Dialog handler set. Next dialog will be ${action}.`,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: 'text', text: message }],
|
|
270
|
+
isError: true,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|