@playwright/mcp 0.0.31 → 0.0.33
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 +27 -6
- package/config.d.ts +5 -0
- package/index.d.ts +1 -6
- package/lib/browserContextFactory.js +64 -54
- package/lib/browserServerBackend.js +121 -0
- package/lib/config.js +10 -9
- package/lib/context.js +107 -182
- package/lib/extension/cdpRelay.js +346 -0
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/extension/main.js +26 -0
- package/lib/httpServer.js +20 -182
- package/lib/index.js +6 -3
- package/lib/loop/loop.js +69 -0
- package/lib/loop/loopClaude.js +152 -0
- package/lib/loop/loopOpenAI.js +141 -0
- package/lib/loop/main.js +60 -0
- package/lib/loopTools/context.js +66 -0
- package/lib/loopTools/main.js +49 -0
- package/lib/loopTools/perform.js +32 -0
- package/lib/loopTools/snapshot.js +29 -0
- package/lib/loopTools/tool.js +18 -0
- package/lib/mcp/inProcessTransport.js +72 -0
- package/lib/mcp/server.js +93 -0
- package/lib/{transport.js → mcp/transport.js} +30 -42
- package/lib/package.js +3 -3
- package/lib/program.js +39 -9
- package/lib/response.js +165 -0
- package/lib/sessionLog.js +121 -0
- package/lib/tab.js +138 -24
- package/lib/tools/common.js +10 -23
- package/lib/tools/console.js +4 -15
- package/lib/tools/dialogs.js +12 -17
- package/lib/tools/evaluate.js +12 -21
- package/lib/tools/files.js +9 -16
- package/lib/tools/install.js +3 -7
- package/lib/tools/keyboard.js +28 -42
- package/lib/tools/mouse.js +27 -50
- package/lib/tools/navigate.js +12 -35
- package/lib/tools/network.js +5 -15
- package/lib/tools/pdf.js +7 -16
- package/lib/tools/screenshot.js +35 -33
- package/lib/tools/snapshot.js +44 -69
- package/lib/tools/tabs.js +10 -41
- package/lib/tools/tool.js +15 -0
- package/lib/tools/utils.js +2 -9
- package/lib/tools/wait.js +3 -6
- package/lib/tools.js +3 -0
- package/lib/utils.js +26 -0
- package/package.json +11 -6
- package/lib/connection.js +0 -81
- package/lib/pageSnapshot.js +0 -43
- package/lib/server.js +0 -48
package/lib/context.js
CHANGED
|
@@ -14,58 +14,46 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import debug from 'debug';
|
|
17
|
-
import {
|
|
18
|
-
import { ManualPromise } from './manualPromise.js';
|
|
17
|
+
import { logUnhandledError } from './log.js';
|
|
19
18
|
import { Tab } from './tab.js';
|
|
20
19
|
import { outputFile } from './config.js';
|
|
21
20
|
const testDebug = debug('pw:mcp:test');
|
|
22
21
|
export class Context {
|
|
23
22
|
tools;
|
|
24
23
|
config;
|
|
24
|
+
sessionLog;
|
|
25
|
+
options;
|
|
25
26
|
_browserContextPromise;
|
|
26
27
|
_browserContextFactory;
|
|
27
28
|
_tabs = [];
|
|
28
29
|
_currentTab;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
30
|
+
_clientInfo;
|
|
31
|
+
static _allContexts = new Set();
|
|
32
|
+
_closeBrowserContextPromise;
|
|
33
|
+
_isRunningTool = false;
|
|
34
|
+
_abortController = new AbortController();
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.tools = options.tools;
|
|
37
|
+
this.config = options.config;
|
|
38
|
+
this.sessionLog = options.sessionLog;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this._browserContextFactory = options.browserContextFactory;
|
|
41
|
+
this._clientInfo = options.clientInfo;
|
|
37
42
|
testDebug('create context');
|
|
43
|
+
Context._allContexts.add(this);
|
|
38
44
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return false;
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
modalStates() {
|
|
45
|
-
return this._modalStates;
|
|
46
|
-
}
|
|
47
|
-
setModalState(modalState, inTab) {
|
|
48
|
-
this._modalStates.push({ ...modalState, tab: inTab });
|
|
49
|
-
}
|
|
50
|
-
clearModalState(modalState) {
|
|
51
|
-
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
52
|
-
}
|
|
53
|
-
modalStatesMarkdown() {
|
|
54
|
-
const result = ['### Modal state'];
|
|
55
|
-
if (this._modalStates.length === 0)
|
|
56
|
-
result.push('- There is no modal state present');
|
|
57
|
-
for (const state of this._modalStates) {
|
|
58
|
-
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
59
|
-
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
60
|
-
}
|
|
61
|
-
return result;
|
|
45
|
+
static async disposeAll() {
|
|
46
|
+
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
|
62
47
|
}
|
|
63
48
|
tabs() {
|
|
64
49
|
return this._tabs;
|
|
65
50
|
}
|
|
51
|
+
currentTab() {
|
|
52
|
+
return this._currentTab;
|
|
53
|
+
}
|
|
66
54
|
currentTabOrDie() {
|
|
67
55
|
if (!this._currentTab)
|
|
68
|
-
throw new Error('No
|
|
56
|
+
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
|
69
57
|
return this._currentTab;
|
|
70
58
|
}
|
|
71
59
|
async newTab() {
|
|
@@ -75,8 +63,12 @@ export class Context {
|
|
|
75
63
|
return this._currentTab;
|
|
76
64
|
}
|
|
77
65
|
async selectTab(index) {
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
const tab = this._tabs[index];
|
|
67
|
+
if (!tab)
|
|
68
|
+
throw new Error(`Tab ${index} not found`);
|
|
69
|
+
await tab.page.bringToFront();
|
|
70
|
+
this._currentTab = tab;
|
|
71
|
+
return tab;
|
|
80
72
|
}
|
|
81
73
|
async ensureTab() {
|
|
82
74
|
const { browserContext } = await this._ensureBrowserContext();
|
|
@@ -84,147 +76,16 @@ export class Context {
|
|
|
84
76
|
await browserContext.newPage();
|
|
85
77
|
return this._currentTab;
|
|
86
78
|
}
|
|
87
|
-
async listTabsMarkdown() {
|
|
88
|
-
if (!this._tabs.length)
|
|
89
|
-
return '### No tabs open';
|
|
90
|
-
const lines = ['### Open tabs'];
|
|
91
|
-
for (let i = 0; i < this._tabs.length; i++) {
|
|
92
|
-
const tab = this._tabs[i];
|
|
93
|
-
const title = await tab.title();
|
|
94
|
-
const url = tab.page.url();
|
|
95
|
-
const current = tab === this._currentTab ? ' (current)' : '';
|
|
96
|
-
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
|
97
|
-
}
|
|
98
|
-
return lines.join('\n');
|
|
99
|
-
}
|
|
100
79
|
async closeTab(index) {
|
|
101
80
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
|
108
|
-
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
|
109
|
-
if (resultOverride)
|
|
110
|
-
return resultOverride;
|
|
111
|
-
if (!this._currentTab) {
|
|
112
|
-
return {
|
|
113
|
-
content: [{
|
|
114
|
-
type: 'text',
|
|
115
|
-
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
116
|
-
}],
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
const tab = this.currentTabOrDie();
|
|
120
|
-
// TODO: race against modal dialogs to resolve clicks.
|
|
121
|
-
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
|
122
|
-
try {
|
|
123
|
-
if (waitForNetwork)
|
|
124
|
-
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
|
|
125
|
-
else
|
|
126
|
-
return await action?.() ?? undefined;
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
|
-
if (captureSnapshot && !this._javaScriptBlocked())
|
|
130
|
-
await tab.captureSnapshot();
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
const result = [];
|
|
134
|
-
result.push(`### Ran Playwright code
|
|
135
|
-
\`\`\`js
|
|
136
|
-
${code.join('\n')}
|
|
137
|
-
\`\`\``);
|
|
138
|
-
if (this.modalStates().length) {
|
|
139
|
-
result.push('', ...this.modalStatesMarkdown());
|
|
140
|
-
return {
|
|
141
|
-
content: [{
|
|
142
|
-
type: 'text',
|
|
143
|
-
text: result.join('\n'),
|
|
144
|
-
}],
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
const messages = tab.takeRecentConsoleMessages();
|
|
148
|
-
if (messages.length) {
|
|
149
|
-
result.push('', `### New console messages`);
|
|
150
|
-
for (const message of messages)
|
|
151
|
-
result.push(`- ${trim(message.toString(), 100)}`);
|
|
152
|
-
}
|
|
153
|
-
if (this._downloads.length) {
|
|
154
|
-
result.push('', '### Downloads');
|
|
155
|
-
for (const entry of this._downloads) {
|
|
156
|
-
if (entry.finished)
|
|
157
|
-
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
158
|
-
else
|
|
159
|
-
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (captureSnapshot && tab.hasSnapshot()) {
|
|
163
|
-
if (this.tabs().length > 1)
|
|
164
|
-
result.push('', await this.listTabsMarkdown());
|
|
165
|
-
if (this.tabs().length > 1)
|
|
166
|
-
result.push('', '### Current tab');
|
|
167
|
-
else
|
|
168
|
-
result.push('', '### Page state');
|
|
169
|
-
result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.title()}`);
|
|
170
|
-
result.push(tab.snapshotOrDie().text());
|
|
171
|
-
}
|
|
172
|
-
const content = actionResult?.content ?? [];
|
|
173
|
-
return {
|
|
174
|
-
content: [
|
|
175
|
-
...content,
|
|
176
|
-
{
|
|
177
|
-
type: 'text',
|
|
178
|
-
text: result.join('\n'),
|
|
179
|
-
}
|
|
180
|
-
],
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
async waitForTimeout(time) {
|
|
184
|
-
if (!this._currentTab || this._javaScriptBlocked()) {
|
|
185
|
-
await new Promise(f => setTimeout(f, time));
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
await callOnPageNoTrace(this._currentTab.page, page => {
|
|
189
|
-
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
async _raceAgainstModalDialogs(action) {
|
|
193
|
-
this._pendingAction = {
|
|
194
|
-
dialogShown: new ManualPromise(),
|
|
195
|
-
};
|
|
196
|
-
let result;
|
|
197
|
-
try {
|
|
198
|
-
await Promise.race([
|
|
199
|
-
action().then(r => result = r),
|
|
200
|
-
this._pendingAction.dialogShown,
|
|
201
|
-
]);
|
|
202
|
-
}
|
|
203
|
-
finally {
|
|
204
|
-
this._pendingAction = undefined;
|
|
205
|
-
}
|
|
206
|
-
return result;
|
|
81
|
+
if (!tab)
|
|
82
|
+
throw new Error(`Tab ${index} not found`);
|
|
83
|
+
const url = tab.page.url();
|
|
84
|
+
await tab.page.close();
|
|
85
|
+
return url;
|
|
207
86
|
}
|
|
208
|
-
|
|
209
|
-
return this.
|
|
210
|
-
}
|
|
211
|
-
dialogShown(tab, dialog) {
|
|
212
|
-
this.setModalState({
|
|
213
|
-
type: 'dialog',
|
|
214
|
-
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
215
|
-
dialog,
|
|
216
|
-
}, tab);
|
|
217
|
-
this._pendingAction?.dialogShown.resolve();
|
|
218
|
-
}
|
|
219
|
-
async downloadStarted(tab, download) {
|
|
220
|
-
const entry = {
|
|
221
|
-
download,
|
|
222
|
-
finished: false,
|
|
223
|
-
outputFile: await outputFile(this.config, download.suggestedFilename())
|
|
224
|
-
};
|
|
225
|
-
this._downloads.push(entry);
|
|
226
|
-
await download.saveAs(entry.outputFile);
|
|
227
|
-
entry.finished = true;
|
|
87
|
+
async outputFile(name) {
|
|
88
|
+
return outputFile(this.config, this._clientInfo.rootPath, name);
|
|
228
89
|
}
|
|
229
90
|
_onPageCreated(page) {
|
|
230
91
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
@@ -233,7 +94,6 @@ ${code.join('\n')}
|
|
|
233
94
|
this._currentTab = tab;
|
|
234
95
|
}
|
|
235
96
|
_onPageClosed(tab) {
|
|
236
|
-
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
|
237
97
|
const index = this._tabs.indexOf(tab);
|
|
238
98
|
if (index === -1)
|
|
239
99
|
return;
|
|
@@ -241,9 +101,21 @@ ${code.join('\n')}
|
|
|
241
101
|
if (this._currentTab === tab)
|
|
242
102
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
|
243
103
|
if (!this._tabs.length)
|
|
244
|
-
void this.
|
|
104
|
+
void this.closeBrowserContext();
|
|
105
|
+
}
|
|
106
|
+
async closeBrowserContext() {
|
|
107
|
+
if (!this._closeBrowserContextPromise)
|
|
108
|
+
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
|
109
|
+
await this._closeBrowserContextPromise;
|
|
110
|
+
this._closeBrowserContextPromise = undefined;
|
|
111
|
+
}
|
|
112
|
+
isRunningTool() {
|
|
113
|
+
return this._isRunningTool;
|
|
245
114
|
}
|
|
246
|
-
|
|
115
|
+
setRunningTool(isRunningTool) {
|
|
116
|
+
this._isRunningTool = isRunningTool;
|
|
117
|
+
}
|
|
118
|
+
async _closeBrowserContextImpl() {
|
|
247
119
|
if (!this._browserContextPromise)
|
|
248
120
|
return;
|
|
249
121
|
testDebug('close context');
|
|
@@ -255,6 +127,11 @@ ${code.join('\n')}
|
|
|
255
127
|
await close();
|
|
256
128
|
});
|
|
257
129
|
}
|
|
130
|
+
async dispose() {
|
|
131
|
+
this._abortController.abort('MCP context disposed');
|
|
132
|
+
await this.closeBrowserContext();
|
|
133
|
+
Context._allContexts.delete(this);
|
|
134
|
+
}
|
|
258
135
|
async _setupRequestInterception(context) {
|
|
259
136
|
if (this.config.network?.allowedOrigins?.length) {
|
|
260
137
|
await context.route('**', route => route.abort('blockedbyclient'));
|
|
@@ -276,10 +153,14 @@ ${code.join('\n')}
|
|
|
276
153
|
return this._browserContextPromise;
|
|
277
154
|
}
|
|
278
155
|
async _setupBrowserContext() {
|
|
156
|
+
if (this._closeBrowserContextPromise)
|
|
157
|
+
throw new Error('Another browser context is being closed.');
|
|
279
158
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
280
|
-
const result = await this._browserContextFactory.createContext();
|
|
159
|
+
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
281
160
|
const { browserContext } = result;
|
|
282
161
|
await this._setupRequestInterception(browserContext);
|
|
162
|
+
if (this.sessionLog)
|
|
163
|
+
await InputRecorder.create(this, browserContext);
|
|
283
164
|
for (const page of browserContext.pages())
|
|
284
165
|
this._onPageCreated(page);
|
|
285
166
|
browserContext.on('page', page => this._onPageCreated(page));
|
|
@@ -294,8 +175,52 @@ ${code.join('\n')}
|
|
|
294
175
|
return result;
|
|
295
176
|
}
|
|
296
177
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
178
|
+
export class InputRecorder {
|
|
179
|
+
_context;
|
|
180
|
+
_browserContext;
|
|
181
|
+
constructor(context, browserContext) {
|
|
182
|
+
this._context = context;
|
|
183
|
+
this._browserContext = browserContext;
|
|
184
|
+
}
|
|
185
|
+
static async create(context, browserContext) {
|
|
186
|
+
const recorder = new InputRecorder(context, browserContext);
|
|
187
|
+
await recorder._initialize();
|
|
188
|
+
return recorder;
|
|
189
|
+
}
|
|
190
|
+
async _initialize() {
|
|
191
|
+
const sessionLog = this._context.sessionLog;
|
|
192
|
+
await this._browserContext._enableRecorder({
|
|
193
|
+
mode: 'recording',
|
|
194
|
+
recorderMode: 'api',
|
|
195
|
+
}, {
|
|
196
|
+
actionAdded: (page, data, code) => {
|
|
197
|
+
if (this._context.isRunningTool())
|
|
198
|
+
return;
|
|
199
|
+
const tab = Tab.forPage(page);
|
|
200
|
+
if (tab)
|
|
201
|
+
sessionLog.logUserAction(data.action, tab, code, false);
|
|
202
|
+
},
|
|
203
|
+
actionUpdated: (page, data, code) => {
|
|
204
|
+
if (this._context.isRunningTool())
|
|
205
|
+
return;
|
|
206
|
+
const tab = Tab.forPage(page);
|
|
207
|
+
if (tab)
|
|
208
|
+
sessionLog.logUserAction(data.action, tab, code, true);
|
|
209
|
+
},
|
|
210
|
+
signalAdded: (page, data) => {
|
|
211
|
+
if (this._context.isRunningTool())
|
|
212
|
+
return;
|
|
213
|
+
if (data.signal.name !== 'navigation')
|
|
214
|
+
return;
|
|
215
|
+
const tab = Tab.forPage(page);
|
|
216
|
+
const navigateAction = {
|
|
217
|
+
name: 'navigate',
|
|
218
|
+
url: data.signal.url,
|
|
219
|
+
signals: [],
|
|
220
|
+
};
|
|
221
|
+
if (tab)
|
|
222
|
+
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
301
226
|
}
|