@wordbricks/playwright-mcp 0.1.25 → 0.1.27
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/lib/browserContextFactory.js +616 -0
- package/lib/browserServerBackend.js +86 -0
- package/lib/config.js +302 -0
- package/lib/context.js +320 -0
- package/lib/extension/cdpRelay.js +352 -0
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/frameworkPatterns.js +35 -0
- package/lib/hooks/antiBotDetectionHook.js +178 -0
- package/lib/hooks/core.js +145 -0
- package/lib/hooks/eventConsumer.js +52 -0
- package/lib/hooks/events.js +42 -0
- package/lib/hooks/formatToolCallEvent.js +12 -0
- package/lib/hooks/frameworkStateHook.js +182 -0
- package/lib/hooks/grouping.js +72 -0
- package/lib/hooks/jsonLdDetectionHook.js +182 -0
- package/lib/hooks/networkFilters.js +82 -0
- package/lib/hooks/networkSetup.js +61 -0
- package/lib/hooks/networkTrackingHook.js +67 -0
- package/lib/hooks/pageHeightHook.js +75 -0
- package/lib/hooks/registry.js +41 -0
- package/lib/hooks/requireTabHook.js +26 -0
- package/lib/hooks/schema.js +89 -0
- package/lib/hooks/waitHook.js +33 -0
- package/lib/index.js +41 -0
- package/lib/mcp/inProcessTransport.js +71 -0
- package/lib/mcp/proxyBackend.js +130 -0
- package/lib/mcp/server.js +91 -0
- package/lib/mcp/tool.js +44 -0
- package/lib/mcp/transport.js +188 -0
- package/lib/playwrightTransformer.js +520 -0
- package/lib/program.js +112 -0
- package/lib/response.js +192 -0
- package/lib/sessionLog.js +123 -0
- package/lib/tab.js +251 -0
- package/lib/tools/common.js +55 -0
- package/lib/tools/console.js +33 -0
- package/lib/tools/dialogs.js +50 -0
- package/lib/tools/evaluate.js +62 -0
- package/lib/tools/extractFrameworkState.js +225 -0
- package/lib/tools/files.js +48 -0
- package/lib/tools/form.js +66 -0
- package/lib/tools/getSnapshot.js +36 -0
- package/lib/tools/getVisibleHtml.js +68 -0
- package/lib/tools/install.js +51 -0
- package/lib/tools/keyboard.js +83 -0
- package/lib/tools/mouse.js +97 -0
- package/lib/tools/navigate.js +66 -0
- package/lib/tools/network.js +121 -0
- package/lib/tools/networkDetail.js +238 -0
- package/lib/tools/networkSearch/bodySearch.js +161 -0
- package/lib/tools/networkSearch/grouping.js +37 -0
- package/lib/tools/networkSearch/helpers.js +32 -0
- package/lib/tools/networkSearch/searchHtml.js +76 -0
- package/lib/tools/networkSearch/types.js +1 -0
- package/lib/tools/networkSearch/urlSearch.js +124 -0
- package/lib/tools/networkSearch.js +278 -0
- package/lib/tools/pdf.js +41 -0
- package/lib/tools/repl.js +414 -0
- package/lib/tools/screenshot.js +103 -0
- package/lib/tools/scroll.js +131 -0
- package/lib/tools/snapshot.js +161 -0
- package/lib/tools/tabs.js +62 -0
- package/lib/tools/tool.js +35 -0
- package/lib/tools/utils.js +78 -0
- package/lib/tools/wait.js +60 -0
- package/lib/tools.js +68 -0
- package/lib/utils/adBlockFilter.js +90 -0
- package/lib/utils/codegen.js +55 -0
- package/lib/utils/extensionPath.js +10 -0
- package/lib/utils/fileUtils.js +40 -0
- package/lib/utils/graphql.js +269 -0
- package/lib/utils/guid.js +22 -0
- package/lib/utils/httpServer.js +39 -0
- package/lib/utils/log.js +21 -0
- package/lib/utils/manualPromise.js +111 -0
- package/lib/utils/networkFormat.js +14 -0
- package/lib/utils/package.js +20 -0
- package/lib/utils/result.js +2 -0
- package/lib/utils/sanitizeHtml.js +130 -0
- package/lib/utils/truncate.js +103 -0
- package/lib/utils/withTimeout.js +7 -0
- package/package.json +11 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Err, Ok } from "../utils/result.js";
|
|
2
|
+
import { trackEvent } from "./events.js";
|
|
3
|
+
import { hookNameSchema } from "./schema.js";
|
|
4
|
+
const pageHeightStateMap = new WeakMap();
|
|
5
|
+
const capturePageHeight = async (ctx) => {
|
|
6
|
+
const tab = ctx.tab || ctx.context.currentTab();
|
|
7
|
+
if (!tab)
|
|
8
|
+
return undefined;
|
|
9
|
+
try {
|
|
10
|
+
return await tab.page.evaluate(() => ({
|
|
11
|
+
pageHeight: document.documentElement.scrollHeight,
|
|
12
|
+
scrollY: window.scrollY,
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const pageHeightPreHook = {
|
|
20
|
+
name: hookNameSchema.enum["page-height-pre"],
|
|
21
|
+
handler: async (ctx) => {
|
|
22
|
+
try {
|
|
23
|
+
const initialState = await capturePageHeight(ctx);
|
|
24
|
+
if (!initialState)
|
|
25
|
+
return Ok(undefined);
|
|
26
|
+
// Store initial state for post-hook (keyed by Context)
|
|
27
|
+
pageHeightStateMap.set(ctx.context, initialState);
|
|
28
|
+
return Ok(undefined);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return Err(error);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const pageHeightPostHook = {
|
|
36
|
+
name: hookNameSchema.enum["page-height-post"],
|
|
37
|
+
handler: async (ctx) => {
|
|
38
|
+
try {
|
|
39
|
+
const initialState = pageHeightStateMap.get(ctx.context);
|
|
40
|
+
if (!initialState)
|
|
41
|
+
return Ok(undefined);
|
|
42
|
+
const finalState = await capturePageHeight(ctx);
|
|
43
|
+
if (!finalState)
|
|
44
|
+
return Ok(undefined);
|
|
45
|
+
const heightChange = finalState.pageHeight - initialState.pageHeight;
|
|
46
|
+
const tab = ctx.tab || ctx.context.currentTab();
|
|
47
|
+
const url = tab ? tab.page.url() : "unknown";
|
|
48
|
+
trackEvent(ctx.context, {
|
|
49
|
+
type: "page-height-change",
|
|
50
|
+
data: {
|
|
51
|
+
previousHeight: initialState.pageHeight,
|
|
52
|
+
currentHeight: finalState.pageHeight,
|
|
53
|
+
heightChange: heightChange,
|
|
54
|
+
scrollY: finalState.scrollY,
|
|
55
|
+
url: url,
|
|
56
|
+
},
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
return Ok(undefined);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return Err(error);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
export const pageHeightHooks = {
|
|
67
|
+
pre: pageHeightPreHook,
|
|
68
|
+
post: pageHeightPostHook,
|
|
69
|
+
};
|
|
70
|
+
export const formatPageHeightEvent = (event) => {
|
|
71
|
+
const { previousHeight, currentHeight, heightChange } = event.data;
|
|
72
|
+
if (heightChange === 0)
|
|
73
|
+
return `Page height unchanged.`;
|
|
74
|
+
return `Page height changed: ${previousHeight}px → ${currentHeight}px (${heightChange > 0 ? "+" : ""}${heightChange}px)`;
|
|
75
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { pipe, reduce, values } from "@fxts/core";
|
|
2
|
+
import { antiBotDetectionHooks } from "./antiBotDetectionHook.js";
|
|
3
|
+
import { createHookRegistry, setToolHooks } from "./core.js";
|
|
4
|
+
import { frameworkStateHooks } from "./frameworkStateHook.js";
|
|
5
|
+
import { registerGroupingRule } from "./grouping.js";
|
|
6
|
+
import { jsonLdDetectionHooks } from "./jsonLdDetectionHook.js";
|
|
7
|
+
import { networkGroupingRule, networkTrackingHooks, } from "./networkTrackingHook.js";
|
|
8
|
+
import { pageHeightHooks } from "./pageHeightHook.js";
|
|
9
|
+
import { requireTabHooks } from "./requireTabHook.js";
|
|
10
|
+
import { toolNameSchema } from "./schema.js";
|
|
11
|
+
import { waitHooks } from "./waitHook.js";
|
|
12
|
+
const COMMON_HOOKS = {
|
|
13
|
+
preHooks: [
|
|
14
|
+
requireTabHooks.pre,
|
|
15
|
+
networkTrackingHooks.pre,
|
|
16
|
+
antiBotDetectionHooks.pre,
|
|
17
|
+
pageHeightHooks.pre,
|
|
18
|
+
frameworkStateHooks.pre,
|
|
19
|
+
jsonLdDetectionHooks.pre,
|
|
20
|
+
],
|
|
21
|
+
postHooks: [
|
|
22
|
+
networkTrackingHooks.post,
|
|
23
|
+
antiBotDetectionHooks.post,
|
|
24
|
+
frameworkStateHooks.post,
|
|
25
|
+
jsonLdDetectionHooks.post,
|
|
26
|
+
pageHeightHooks.post,
|
|
27
|
+
waitHooks.post,
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
const toolHooksConfig = [
|
|
31
|
+
// Example hook registration
|
|
32
|
+
// {
|
|
33
|
+
// toolName: toolNameSchema.enum.browser_click,
|
|
34
|
+
// preHooks: [],
|
|
35
|
+
// postHooks: [],
|
|
36
|
+
// },
|
|
37
|
+
];
|
|
38
|
+
export const buildHookRegistry = () => {
|
|
39
|
+
registerGroupingRule("network-request", networkGroupingRule);
|
|
40
|
+
return pipe(createHookRegistry(), (registry) => reduce((acc, toolName) => setToolHooks(acc, { toolName, ...COMMON_HOOKS }), registry, values(toolNameSchema.enum)), (registry) => reduce(setToolHooks, registry, toolHooksConfig));
|
|
41
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Err, Ok } from "../utils/result.js";
|
|
2
|
+
import { hookNameSchema, toolNameSchema } from "./schema.js";
|
|
3
|
+
const MESSAGE = 'No open tabs. Use the "browser_navigate" tool to navigate to a page first.';
|
|
4
|
+
const requireTabPreHook = {
|
|
5
|
+
name: hookNameSchema.enum["require-tab-pre"],
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
try {
|
|
8
|
+
// Allow navigate tool to create/open a tab
|
|
9
|
+
if (ctx.toolName === toolNameSchema.enum.browser_navigate)
|
|
10
|
+
return Ok(undefined);
|
|
11
|
+
// If no current tab, emit a standardized error and stop execution
|
|
12
|
+
if (!ctx.context.currentTab()) {
|
|
13
|
+
// Include tabs section in response so the message is rendered consistently
|
|
14
|
+
ctx.response.setIncludeTabs();
|
|
15
|
+
return Err(new Error(MESSAGE));
|
|
16
|
+
}
|
|
17
|
+
return Ok(undefined);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return Err(error);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export const requireTabHooks = {
|
|
25
|
+
pre: requireTabPreHook,
|
|
26
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const hookNameSchema = z.enum([
|
|
3
|
+
"network-tracking-pre",
|
|
4
|
+
"network-tracking-post",
|
|
5
|
+
"page-height-pre",
|
|
6
|
+
"page-height-post",
|
|
7
|
+
"wait-post",
|
|
8
|
+
"framework-state-pre",
|
|
9
|
+
"framework-state-post",
|
|
10
|
+
"json-ld-detection-pre",
|
|
11
|
+
"json-ld-detection-post",
|
|
12
|
+
"require-tab-pre",
|
|
13
|
+
"anti-bot-detection-pre",
|
|
14
|
+
"anti-bot-detection-post",
|
|
15
|
+
]);
|
|
16
|
+
// Tool names enum - should match actual tool names in
|
|
17
|
+
export const toolNameSchema = z.enum([
|
|
18
|
+
"browser_click",
|
|
19
|
+
"browser_extract_framework_state",
|
|
20
|
+
"browser_fill_form",
|
|
21
|
+
"browser_get_snapshot",
|
|
22
|
+
// 'browser_get_visible_html',
|
|
23
|
+
"browser_navigate",
|
|
24
|
+
// 'browser_navigate_back',
|
|
25
|
+
// 'browser_navigate_forward',
|
|
26
|
+
"browser_network_detail",
|
|
27
|
+
"browser_network_search",
|
|
28
|
+
"browser_press_key",
|
|
29
|
+
"browser_reload",
|
|
30
|
+
"browser_repl",
|
|
31
|
+
"browser_scroll",
|
|
32
|
+
"browser_type",
|
|
33
|
+
"browser_wait",
|
|
34
|
+
]);
|
|
35
|
+
export const EventTypeSchema = z.enum([
|
|
36
|
+
"network-request",
|
|
37
|
+
"page-height-change",
|
|
38
|
+
"wait",
|
|
39
|
+
"tool-call",
|
|
40
|
+
"framework-state",
|
|
41
|
+
"json-ld",
|
|
42
|
+
"anti-bot",
|
|
43
|
+
]);
|
|
44
|
+
export const NetworkRequestEventDataSchema = z.object({
|
|
45
|
+
method: z.string(),
|
|
46
|
+
url: z.string(),
|
|
47
|
+
status: z.number(),
|
|
48
|
+
resourceType: z.string(),
|
|
49
|
+
postData: z.string().optional(),
|
|
50
|
+
setCookies: z.array(z.string()).optional(),
|
|
51
|
+
responseSize: z.number().optional(),
|
|
52
|
+
});
|
|
53
|
+
export const PageHeightChangeEventDataSchema = z.object({
|
|
54
|
+
previousHeight: z.number(),
|
|
55
|
+
currentHeight: z.number(),
|
|
56
|
+
heightChange: z.number(),
|
|
57
|
+
scrollY: z.number(),
|
|
58
|
+
url: z.string(),
|
|
59
|
+
});
|
|
60
|
+
export const WaitEventDataSchema = z.object({
|
|
61
|
+
duration: z.number(),
|
|
62
|
+
});
|
|
63
|
+
export const ToolCallEventDataSchema = z.object({
|
|
64
|
+
toolName: z.string(),
|
|
65
|
+
params: z.record(z.unknown()).optional(),
|
|
66
|
+
executionTime: z.number().optional(),
|
|
67
|
+
success: z.boolean().optional(),
|
|
68
|
+
});
|
|
69
|
+
export const FrameworkStateEventDataSchema = z.object({
|
|
70
|
+
state: z.record(z.any()),
|
|
71
|
+
changes: z.array(z.string()).optional(),
|
|
72
|
+
action: z.enum(["detected", "changed"]),
|
|
73
|
+
});
|
|
74
|
+
export const JsonLdEventDataSchema = z.object({
|
|
75
|
+
state: z.record(z.object({
|
|
76
|
+
count: z.number(),
|
|
77
|
+
indices: z.array(z.number()),
|
|
78
|
+
})),
|
|
79
|
+
changes: z.array(z.string()).optional(),
|
|
80
|
+
action: z.enum(["detected", "changed"]),
|
|
81
|
+
});
|
|
82
|
+
export const AntiBotEventDataSchema = z.object({
|
|
83
|
+
provider: z.enum(["cloudflare-turnstile", "aws-waf"]),
|
|
84
|
+
detectionMethod: z.literal("network-request"),
|
|
85
|
+
url: z.string(),
|
|
86
|
+
status: z.number(),
|
|
87
|
+
action: z.enum(["detected", "resolved", "still-blocked"]),
|
|
88
|
+
waitMs: z.number().optional(),
|
|
89
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import ms from "ms";
|
|
2
|
+
import { Err, Ok } from "../utils/result.js";
|
|
3
|
+
import { trackEvent } from "./events.js";
|
|
4
|
+
import { hookNameSchema } from "./schema.js";
|
|
5
|
+
export const WAIT_TIME_STR = "0.5s";
|
|
6
|
+
export const WAIT_TIME_MS = ms(WAIT_TIME_STR);
|
|
7
|
+
const waitPostHook = {
|
|
8
|
+
name: hookNameSchema.enum["wait-post"],
|
|
9
|
+
handler: async (ctx) => {
|
|
10
|
+
try {
|
|
11
|
+
const tab = ctx.tab || ctx.context.currentTab();
|
|
12
|
+
if (!tab)
|
|
13
|
+
return Ok(undefined);
|
|
14
|
+
const startTime = Date.now();
|
|
15
|
+
await tab.page.waitForTimeout(WAIT_TIME_MS);
|
|
16
|
+
trackEvent(ctx.context, {
|
|
17
|
+
type: "wait",
|
|
18
|
+
data: {
|
|
19
|
+
duration: WAIT_TIME_MS,
|
|
20
|
+
},
|
|
21
|
+
timestamp: startTime,
|
|
22
|
+
});
|
|
23
|
+
return Ok(undefined);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return Err(error);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export const waitHooks = {
|
|
31
|
+
post: waitPostHook,
|
|
32
|
+
};
|
|
33
|
+
export const formatWaitEvent = (event) => `Waited ${event.data.duration}ms`;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { contextFactory } from "./browserContextFactory.js";
|
|
17
|
+
import { BrowserServerBackend } from "./browserServerBackend.js";
|
|
18
|
+
import { resolveConfig } from "./config.js";
|
|
19
|
+
import * as mcpServer from "./mcp/server.js";
|
|
20
|
+
export async function createConnection(userConfig, contextGetter) {
|
|
21
|
+
const config = await resolveConfig(userConfig || {});
|
|
22
|
+
const factory = contextGetter
|
|
23
|
+
? new SimpleBrowserContextFactory(contextGetter)
|
|
24
|
+
: contextFactory(config);
|
|
25
|
+
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
|
26
|
+
}
|
|
27
|
+
class SimpleBrowserContextFactory {
|
|
28
|
+
name = "custom";
|
|
29
|
+
description = "Connect to a browser using a custom context getter";
|
|
30
|
+
_contextGetter;
|
|
31
|
+
constructor(contextGetter) {
|
|
32
|
+
this._contextGetter = contextGetter;
|
|
33
|
+
}
|
|
34
|
+
async createContext() {
|
|
35
|
+
const browserContext = await this._contextGetter();
|
|
36
|
+
return {
|
|
37
|
+
browserContext,
|
|
38
|
+
close: () => browserContext.close(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
export class InProcessTransport {
|
|
17
|
+
_server;
|
|
18
|
+
_serverTransport;
|
|
19
|
+
_connected = false;
|
|
20
|
+
constructor(server) {
|
|
21
|
+
this._server = server;
|
|
22
|
+
this._serverTransport = new InProcessServerTransport(this);
|
|
23
|
+
}
|
|
24
|
+
async start() {
|
|
25
|
+
if (this._connected)
|
|
26
|
+
throw new Error("InprocessTransport already started!");
|
|
27
|
+
await this._server.connect(this._serverTransport);
|
|
28
|
+
this._connected = true;
|
|
29
|
+
}
|
|
30
|
+
async send(message, options) {
|
|
31
|
+
if (!this._connected)
|
|
32
|
+
throw new Error("Transport not connected");
|
|
33
|
+
this._serverTransport._receiveFromClient(message);
|
|
34
|
+
}
|
|
35
|
+
async close() {
|
|
36
|
+
if (this._connected) {
|
|
37
|
+
this._connected = false;
|
|
38
|
+
this.onclose?.();
|
|
39
|
+
this._serverTransport.onclose?.();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
onclose;
|
|
43
|
+
onerror;
|
|
44
|
+
onmessage;
|
|
45
|
+
sessionId;
|
|
46
|
+
setProtocolVersion;
|
|
47
|
+
_receiveFromServer(message, extra) {
|
|
48
|
+
this.onmessage?.(message, extra);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class InProcessServerTransport {
|
|
52
|
+
_clientTransport;
|
|
53
|
+
constructor(clientTransport) {
|
|
54
|
+
this._clientTransport = clientTransport;
|
|
55
|
+
}
|
|
56
|
+
async start() { }
|
|
57
|
+
async send(message, options) {
|
|
58
|
+
this._clientTransport._receiveFromServer(message);
|
|
59
|
+
}
|
|
60
|
+
async close() {
|
|
61
|
+
this.onclose?.();
|
|
62
|
+
}
|
|
63
|
+
onclose;
|
|
64
|
+
onerror;
|
|
65
|
+
onmessage;
|
|
66
|
+
sessionId;
|
|
67
|
+
setProtocolVersion;
|
|
68
|
+
_receiveFromClient(message) {
|
|
69
|
+
this.onmessage?.(message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { ListRootsRequestSchema, PingRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
20
|
+
import { logUnhandledError } from "../utils/log.js";
|
|
21
|
+
import { packageJSON } from "../utils/package.js";
|
|
22
|
+
export class ProxyBackend {
|
|
23
|
+
name = "Playwright MCP Client Switcher";
|
|
24
|
+
version = packageJSON.version;
|
|
25
|
+
_mcpProviders;
|
|
26
|
+
_currentClient;
|
|
27
|
+
_contextSwitchTool;
|
|
28
|
+
_roots = [];
|
|
29
|
+
constructor(mcpProviders) {
|
|
30
|
+
this._mcpProviders = mcpProviders;
|
|
31
|
+
this._contextSwitchTool = this._defineContextSwitchTool();
|
|
32
|
+
}
|
|
33
|
+
async initialize(server) {
|
|
34
|
+
if (this._currentClient)
|
|
35
|
+
return;
|
|
36
|
+
const version = server.getClientVersion();
|
|
37
|
+
const capabilities = server.getClientCapabilities();
|
|
38
|
+
if (capabilities?.roots &&
|
|
39
|
+
version &&
|
|
40
|
+
clientsWithRoots.includes(version.name)) {
|
|
41
|
+
const { roots } = await server.listRoots();
|
|
42
|
+
this._roots = roots;
|
|
43
|
+
}
|
|
44
|
+
await this._setCurrentClient(this._mcpProviders[0]);
|
|
45
|
+
}
|
|
46
|
+
async listTools() {
|
|
47
|
+
const response = await this._currentClient.listTools();
|
|
48
|
+
if (this._mcpProviders.length === 1)
|
|
49
|
+
return response.tools;
|
|
50
|
+
return [...response.tools, this._contextSwitchTool];
|
|
51
|
+
}
|
|
52
|
+
async callTool(name, args) {
|
|
53
|
+
if (name === this._contextSwitchTool.name)
|
|
54
|
+
return this._callContextSwitchTool(args);
|
|
55
|
+
return (await this._currentClient.callTool({
|
|
56
|
+
name,
|
|
57
|
+
arguments: args,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
serverClosed() {
|
|
61
|
+
void this._currentClient?.close().catch(logUnhandledError);
|
|
62
|
+
this._currentClient = undefined;
|
|
63
|
+
}
|
|
64
|
+
async _callContextSwitchTool(params) {
|
|
65
|
+
try {
|
|
66
|
+
const factory = this._mcpProviders.find((factory) => factory.name === params.name);
|
|
67
|
+
if (!factory)
|
|
68
|
+
throw new Error("Unknown connection method: " + params.name);
|
|
69
|
+
await this._setCurrentClient(factory);
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: "### Result\nSuccessfully changed connection method.\n",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: `### Result\nError: ${error}\n` }],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
_defineContextSwitchTool() {
|
|
87
|
+
return {
|
|
88
|
+
name: "browser_connect",
|
|
89
|
+
description: [
|
|
90
|
+
"Connect to a browser using one of the available methods:",
|
|
91
|
+
...this._mcpProviders.map((factory) => `- "${factory.name}": ${factory.description}`),
|
|
92
|
+
].join("\n"),
|
|
93
|
+
inputSchema: zodToJsonSchema(z.object({
|
|
94
|
+
name: z
|
|
95
|
+
.enum(this._mcpProviders.map((factory) => factory.name))
|
|
96
|
+
.default(this._mcpProviders[0].name)
|
|
97
|
+
.describe("The method to use to connect to the browser"),
|
|
98
|
+
}), { strictUnions: true }),
|
|
99
|
+
annotations: {
|
|
100
|
+
title: "Connect to a browser context",
|
|
101
|
+
readOnlyHint: true,
|
|
102
|
+
openWorldHint: false,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async _setCurrentClient(factory) {
|
|
107
|
+
await this._currentClient?.close();
|
|
108
|
+
this._currentClient = undefined;
|
|
109
|
+
const client = new Client({
|
|
110
|
+
name: "Playwright MCP Proxy",
|
|
111
|
+
version: packageJSON.version,
|
|
112
|
+
});
|
|
113
|
+
client.registerCapabilities({
|
|
114
|
+
roots: {
|
|
115
|
+
listChanged: true,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
client.setRequestHandler(ListRootsRequestSchema, () => ({
|
|
119
|
+
roots: this._roots,
|
|
120
|
+
}));
|
|
121
|
+
client.setRequestHandler(PingRequestSchema, () => ({}));
|
|
122
|
+
const transport = await factory.connect();
|
|
123
|
+
await client.connect(transport);
|
|
124
|
+
this._currentClient = client;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const clientsWithRoots = [
|
|
128
|
+
"Visual Studio Code",
|
|
129
|
+
"Visual Studio Code - Insiders",
|
|
130
|
+
];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
17
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import debug from "debug";
|
|
19
|
+
import ms from "ms";
|
|
20
|
+
import { logUnhandledError } from "../utils/log.js";
|
|
21
|
+
import { ManualPromise } from "../utils/manualPromise.js";
|
|
22
|
+
const serverDebug = debug("pw:mcp:server");
|
|
23
|
+
export async function connect(serverBackendFactory, transport, runHeartbeat) {
|
|
24
|
+
const backend = serverBackendFactory();
|
|
25
|
+
const server = createServer(backend, runHeartbeat);
|
|
26
|
+
await server.connect(transport);
|
|
27
|
+
return { name: backend.name, version: backend.version };
|
|
28
|
+
}
|
|
29
|
+
export function createServer(backend, runHeartbeat) {
|
|
30
|
+
const initializedPromise = new ManualPromise();
|
|
31
|
+
const server = new Server({ name: backend.name, version: backend.version }, {
|
|
32
|
+
capabilities: {
|
|
33
|
+
tools: {},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
37
|
+
serverDebug("listTools");
|
|
38
|
+
await initializedPromise;
|
|
39
|
+
const tools = await backend.listTools();
|
|
40
|
+
return { tools };
|
|
41
|
+
});
|
|
42
|
+
let heartbeatRunning = false;
|
|
43
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
44
|
+
serverDebug("callTool", request);
|
|
45
|
+
await initializedPromise;
|
|
46
|
+
if (runHeartbeat && !heartbeatRunning) {
|
|
47
|
+
heartbeatRunning = true;
|
|
48
|
+
startHeartbeat(server);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await backend.callTool(request.params.name, request.params.arguments || {});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
content: [{ type: "text", text: "### Result\n" + String(error) }],
|
|
56
|
+
isError: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
addServerListener(server, "initialized", () => {
|
|
61
|
+
backend
|
|
62
|
+
.initialize?.(server)
|
|
63
|
+
.then(() => initializedPromise.resolve())
|
|
64
|
+
.catch(logUnhandledError);
|
|
65
|
+
});
|
|
66
|
+
addServerListener(server, "close", () => backend.serverClosed?.());
|
|
67
|
+
return server;
|
|
68
|
+
}
|
|
69
|
+
const startHeartbeat = (server) => {
|
|
70
|
+
const beat = () => {
|
|
71
|
+
serverDebug("Health check...");
|
|
72
|
+
Promise.race([
|
|
73
|
+
server.ping(),
|
|
74
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("ping timeout")), ms("5s"))),
|
|
75
|
+
])
|
|
76
|
+
.then(() => {
|
|
77
|
+
setTimeout(beat, ms("3s"));
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {
|
|
80
|
+
void server.close();
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
beat();
|
|
84
|
+
};
|
|
85
|
+
function addServerListener(server, event, listener) {
|
|
86
|
+
const oldListener = server[`on${event}`];
|
|
87
|
+
server[`on${event}`] = () => {
|
|
88
|
+
oldListener?.();
|
|
89
|
+
listener();
|
|
90
|
+
};
|
|
91
|
+
}
|
package/lib/mcp/tool.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
18
|
+
const typesWithIntent = ["action", "assertion", "input"];
|
|
19
|
+
export function toMcpTool(tool, options) {
|
|
20
|
+
const inputSchema = options?.addIntent && typesWithIntent.includes(tool.type)
|
|
21
|
+
? tool.inputSchema.extend({
|
|
22
|
+
intent: z
|
|
23
|
+
.string()
|
|
24
|
+
.describe("The intent of the call, for example the test step description plan idea"),
|
|
25
|
+
})
|
|
26
|
+
: tool.inputSchema;
|
|
27
|
+
const readOnly = tool.type === "readOnly" || tool.type === "assertion";
|
|
28
|
+
return {
|
|
29
|
+
name: tool.name,
|
|
30
|
+
description: tool.description,
|
|
31
|
+
inputSchema: zodToJsonSchema(inputSchema, {
|
|
32
|
+
strictUnions: true,
|
|
33
|
+
}),
|
|
34
|
+
annotations: {
|
|
35
|
+
title: tool.title,
|
|
36
|
+
readOnlyHint: readOnly,
|
|
37
|
+
destructiveHint: !readOnly,
|
|
38
|
+
openWorldHint: true,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function defineToolSchema(tool) {
|
|
43
|
+
return tool;
|
|
44
|
+
}
|