@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.
Files changed (52) hide show
  1. package/README.md +27 -6
  2. package/config.d.ts +5 -0
  3. package/index.d.ts +1 -6
  4. package/lib/browserContextFactory.js +64 -54
  5. package/lib/browserServerBackend.js +121 -0
  6. package/lib/config.js +10 -9
  7. package/lib/context.js +107 -182
  8. package/lib/extension/cdpRelay.js +346 -0
  9. package/lib/extension/extensionContextFactory.js +56 -0
  10. package/lib/extension/main.js +26 -0
  11. package/lib/httpServer.js +20 -182
  12. package/lib/index.js +6 -3
  13. package/lib/loop/loop.js +69 -0
  14. package/lib/loop/loopClaude.js +152 -0
  15. package/lib/loop/loopOpenAI.js +141 -0
  16. package/lib/loop/main.js +60 -0
  17. package/lib/loopTools/context.js +66 -0
  18. package/lib/loopTools/main.js +49 -0
  19. package/lib/loopTools/perform.js +32 -0
  20. package/lib/loopTools/snapshot.js +29 -0
  21. package/lib/loopTools/tool.js +18 -0
  22. package/lib/mcp/inProcessTransport.js +72 -0
  23. package/lib/mcp/server.js +93 -0
  24. package/lib/{transport.js → mcp/transport.js} +30 -42
  25. package/lib/package.js +3 -3
  26. package/lib/program.js +39 -9
  27. package/lib/response.js +165 -0
  28. package/lib/sessionLog.js +121 -0
  29. package/lib/tab.js +138 -24
  30. package/lib/tools/common.js +10 -23
  31. package/lib/tools/console.js +4 -15
  32. package/lib/tools/dialogs.js +12 -17
  33. package/lib/tools/evaluate.js +12 -21
  34. package/lib/tools/files.js +9 -16
  35. package/lib/tools/install.js +3 -7
  36. package/lib/tools/keyboard.js +28 -42
  37. package/lib/tools/mouse.js +27 -50
  38. package/lib/tools/navigate.js +12 -35
  39. package/lib/tools/network.js +5 -15
  40. package/lib/tools/pdf.js +7 -16
  41. package/lib/tools/screenshot.js +35 -33
  42. package/lib/tools/snapshot.js +44 -69
  43. package/lib/tools/tabs.js +10 -41
  44. package/lib/tools/tool.js +15 -0
  45. package/lib/tools/utils.js +2 -9
  46. package/lib/tools/wait.js +3 -6
  47. package/lib/tools.js +3 -0
  48. package/lib/utils.js +26 -0
  49. package/package.json +11 -6
  50. package/lib/connection.js +0 -81
  51. package/lib/pageSnapshot.js +0 -43
  52. 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 { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
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
- _modalStates = [];
30
- _pendingAction;
31
- _downloads = [];
32
- clientVersion;
33
- constructor(tools, config, browserContextFactory) {
34
- this.tools = tools;
35
- this.config = config;
36
- this._browserContextFactory = browserContextFactory;
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
- clientSupportsImages() {
40
- if (this.config.imageResponses === 'omit')
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 current snapshot available. Capture a snapshot or navigate to a new location first.');
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
- this._currentTab = this._tabs[index];
79
- await this._currentTab.page.bringToFront();
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
- await tab?.page.close();
103
- return await this.listTabsMarkdown();
104
- }
105
- async run(tool, params) {
106
- // Tab management is done outside of the action() call.
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
- _javaScriptBlocked() {
209
- return this._modalStates.some(state => state.type === 'dialog');
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.close();
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
- async close() {
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
- function trim(text, maxLength) {
298
- if (text.length <= maxLength)
299
- return text;
300
- return text.slice(0, maxLength) + '...';
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
  }