ohos-playwright-mcp 0.2.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/LICENSE +21 -0
- package/README.md +90 -0
- package/package.json +47 -0
- package/server.mjs +752 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 social4hyq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ohos-playwright-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for **HarmonyOS / OpenHarmony ArkWeb** (Chromium 132-based) — the `ohos` counterpart of [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp).
|
|
4
|
+
|
|
5
|
+
Drives ArkWeb through [`playwright-core`](https://www.npmjs.com/package/playwright-core) over the Chrome DevTools Protocol. Bootstrap (hdc connect → `aa start` → `hdc fport` → CDP endpoint) is delegated to [`ohos-playwright`](https://github.com/social4hyq/ohos-playwright).
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
ArkWeb on HarmonyOS denies `AF_UNIX` socket creation in its sandbox, which breaks any tool that tries to launch Chrome the playwright way. `connectOverCDP` over a TCP-forwarded port works fine, and that's what this server uses end-to-end.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -g ohos-playwright-mcp ohos-playwright playwright-core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node ≥ 24. `hdc` must be on `PATH` and an OpenHarmony / HarmonyOS device reachable.
|
|
18
|
+
|
|
19
|
+
## MCP client config
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"ohos": {
|
|
25
|
+
"command": "ohos-playwright-mcp"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If the peer deps live in a non-standard location, point at them explicitly:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"ohos": {
|
|
37
|
+
"command": "node",
|
|
38
|
+
"args": ["/abs/path/to/server.mjs"],
|
|
39
|
+
"env": {
|
|
40
|
+
"ARKWEB_OHOS_PW_REGISTER": "/abs/path/to/ohos-playwright/dist/register.mjs",
|
|
41
|
+
"ARKWEB_OHOS_PW_SETUP": "/abs/path/to/ohos-playwright/dist/setup.mjs",
|
|
42
|
+
"ARKWEB_PW_CORE": "/abs/path/to/playwright-core/index.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Other env vars:
|
|
50
|
+
|
|
51
|
+
- `OHOS_PW_INFO_PATH` — where the CDP endpoint cache lives (default: `<tmpdir>/ohos-playwright-cdp.json`).
|
|
52
|
+
- Any `OHOS_PW_*` vars consumed by `ohos-playwright/setup` (device serial, browser bundle name, port, etc.) — see that project's README.
|
|
53
|
+
|
|
54
|
+
## Tools (61)
|
|
55
|
+
|
|
56
|
+
**Navigation** — `navigate`, `navigate_back`, `navigate_forward`, `reload`, `wait`, `wait_for`
|
|
57
|
+
|
|
58
|
+
**Read-only** — `evaluate`, `get_text`, `get_html`, `screenshot`, `snapshot`
|
|
59
|
+
|
|
60
|
+
**Tabs / lifecycle** — `list_pages`, `select_page`, `tab_new`, `tab_close`, `close`, `resize`
|
|
61
|
+
|
|
62
|
+
**Input (selector-based)** — `click`, `hover`, `type`, `fill`, `fill_form`, `press_key`, `select_option`, `file_upload`, `drag`
|
|
63
|
+
|
|
64
|
+
**Input (raw mouse)** — `mouse_move_xy`, `mouse_click_xy`, `mouse_down`, `mouse_up`, `mouse_drag_xy`, `mouse_wheel`
|
|
65
|
+
|
|
66
|
+
**Diagnostics** — `console_messages`, `handle_dialog`
|
|
67
|
+
|
|
68
|
+
**Network** — `network_requests`, `network_request`, `network_state_set`, `route`, `route_list`, `unroute`
|
|
69
|
+
|
|
70
|
+
**Cookies** — `cookie_list`, `cookie_get`, `cookie_set`, `cookie_delete`, `cookie_clear`
|
|
71
|
+
|
|
72
|
+
**Storage** — `localstorage_list`, `localstorage_get`, `localstorage_set`, `localstorage_delete`, `localstorage_clear`, `sessionstorage_list`, `sessionstorage_get`, `sessionstorage_set`, `sessionstorage_delete`, `sessionstorage_clear`, `storage_state`
|
|
73
|
+
|
|
74
|
+
**Visualization** — `highlight`, `hide_highlight`
|
|
75
|
+
|
|
76
|
+
**Heavy** — `pdf_save` (may not work on foreground ArkWeb), `start_tracing`, `stop_tracing`
|
|
77
|
+
|
|
78
|
+
Each tool's JSON schema is published via standard MCP `tools/list`.
|
|
79
|
+
|
|
80
|
+
## ArkWeb-specific notes
|
|
81
|
+
|
|
82
|
+
- `screenshot` uses raw CDP `Page.captureScreenshot` to skip Playwright's font-wait, which hangs on some ArkWeb pages.
|
|
83
|
+
- `snapshot` calls `Accessibility.getFullAXTree` via a fresh CDP session because Playwright 1.x removed `page.accessibility`.
|
|
84
|
+
- `tab_new` uses the `/json/new` HTTP endpoint with `PUT` (ArkWeb rejects the playwright `context.newPage()` path).
|
|
85
|
+
- `navigate_back` / `navigate_forward` use `waitUntil: 'commit'` because ArkWeb doesn't re-fire `load` for cached history navigation.
|
|
86
|
+
- ArkWeb tabs can occasionally crash into `arkweb-error://webdata/` under heavy CDP load. The server auto-recovers by spawning a blank tab.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT © 2026 social4hyq
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ohos-playwright-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for HarmonyOS / OpenHarmony ArkWeb — playwright-core over CDP. 61 tools: navigation, input, network, cookies, storage, snapshot, tracing. The ohos counterpart of @playwright/mcp.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ohos-playwright-mcp": "server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.mjs",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"harmonyos",
|
|
18
|
+
"openharmony",
|
|
19
|
+
"ohos",
|
|
20
|
+
"arkweb",
|
|
21
|
+
"cdp",
|
|
22
|
+
"chrome-devtools-protocol",
|
|
23
|
+
"playwright",
|
|
24
|
+
"browser-automation",
|
|
25
|
+
"hdc"
|
|
26
|
+
],
|
|
27
|
+
"author": "social4hyq",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/social4hyq/ohos-playwright-mcp.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/social4hyq/ohos-playwright-mcp#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/social4hyq/ohos-playwright-mcp/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=24"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"ohos-playwright": ">=0.2.2",
|
|
42
|
+
"playwright-core": ">=1.59.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP server for HarmonyOS ArkWeb, backed by playwright-core via connectOverCDP.
|
|
3
|
+
// Bootstrap (hdc connect + browser launch + fport) is delegated to ohos-playwright's setup.
|
|
4
|
+
//
|
|
5
|
+
// register.mjs MUST be imported first — it sets process.platform = 'linux'
|
|
6
|
+
// before playwright-core's hostPlatform detection runs.
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
|
|
15
|
+
function resolveOrThrow(spec, hint) {
|
|
16
|
+
// env override wins
|
|
17
|
+
const envPath = process.env[hint]
|
|
18
|
+
if (envPath) return envPath
|
|
19
|
+
try { return require.resolve(spec) }
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Cannot find "${spec}". Install it as a peer dependency, or set ${hint} to its absolute path. ` +
|
|
23
|
+
`(original: ${e.message})`
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REGISTER_PATH = resolveOrThrow('ohos-playwright/register', 'ARKWEB_OHOS_PW_REGISTER')
|
|
29
|
+
const SETUP_PATH = resolveOrThrow('ohos-playwright/setup', 'ARKWEB_OHOS_PW_SETUP')
|
|
30
|
+
const PW_CORE_PATH = resolveOrThrow('playwright-core', 'ARKWEB_PW_CORE')
|
|
31
|
+
const INFO_PATH = process.env.OHOS_PW_INFO_PATH ?? `${tmpdir()}/ohos-playwright-cdp.json`
|
|
32
|
+
|
|
33
|
+
let cdpEndpoint = null
|
|
34
|
+
|
|
35
|
+
await import(REGISTER_PATH)
|
|
36
|
+
const { chromium } = await import(PW_CORE_PATH)
|
|
37
|
+
|
|
38
|
+
const log = (...a) => console.error('[arkweb-cdp-mcp]', ...a)
|
|
39
|
+
|
|
40
|
+
let browser = null
|
|
41
|
+
let context = null
|
|
42
|
+
let currentPage = null
|
|
43
|
+
let bootstrapPromise = null
|
|
44
|
+
|
|
45
|
+
const consoleBuffer = [] // { pageUrl, type, text, ts }
|
|
46
|
+
const networkRequests = [] // { id, url, method, status, requestHeaders, responseHeaders, postData, fromCache, durationMs }
|
|
47
|
+
const networkById = new Map()
|
|
48
|
+
const dialogQueue = [] // pending dialog handlers; auto-dismiss with default
|
|
49
|
+
let dialogPolicy = { action: 'dismiss', promptText: '' }
|
|
50
|
+
const routeHandlers = new Map() // pattern -> {handler, status: 'active', hits: 0}
|
|
51
|
+
|
|
52
|
+
async function ensureBootstrapped() {
|
|
53
|
+
if (existsSync(INFO_PATH)) {
|
|
54
|
+
try {
|
|
55
|
+
const info = JSON.parse(readFileSync(INFO_PATH, 'utf8'))
|
|
56
|
+
const r = await fetch(`${info.endpoint}/json/version`).catch(() => null)
|
|
57
|
+
if (r?.ok) return info
|
|
58
|
+
log('stale INFO_PATH, re-bootstrapping')
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
const setup = await import(SETUP_PATH)
|
|
62
|
+
await setup.default()
|
|
63
|
+
return JSON.parse(readFileSync(INFO_PATH, 'utf8'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function connect() {
|
|
67
|
+
const info = await ensureBootstrapped()
|
|
68
|
+
cdpEndpoint = info.endpoint
|
|
69
|
+
log('connecting to', info.endpoint)
|
|
70
|
+
browser = await chromium.connectOverCDP(info.endpoint)
|
|
71
|
+
context = browser.contexts()[0]
|
|
72
|
+
if (!context) throw new Error('no browser context')
|
|
73
|
+
|
|
74
|
+
context.on('page', wirePage)
|
|
75
|
+
for (const p of context.pages()) wirePage(p)
|
|
76
|
+
currentPage = context.pages().find(p => !p.url().startsWith('chrome-')) ?? context.pages()[0] ?? null
|
|
77
|
+
|
|
78
|
+
log(`ready: ${context.pages().length} page(s), current=${currentPage?.url() ?? 'none'}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wirePage(page) {
|
|
82
|
+
page.on('console', msg => {
|
|
83
|
+
consoleBuffer.push({
|
|
84
|
+
pageUrl: page.url(), type: msg.type(), text: msg.text(),
|
|
85
|
+
ts: Date.now(),
|
|
86
|
+
})
|
|
87
|
+
if (consoleBuffer.length > 1000) consoleBuffer.splice(0, consoleBuffer.length - 1000)
|
|
88
|
+
})
|
|
89
|
+
page.on('pageerror', err => {
|
|
90
|
+
consoleBuffer.push({ pageUrl: page.url(), type: 'pageerror', text: err.message, ts: Date.now() })
|
|
91
|
+
})
|
|
92
|
+
page.on('dialog', async d => {
|
|
93
|
+
if (dialogPolicy.action === 'accept') await d.accept(dialogPolicy.promptText)
|
|
94
|
+
else await d.dismiss()
|
|
95
|
+
})
|
|
96
|
+
page.on('request', req => {
|
|
97
|
+
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
98
|
+
const entry = {
|
|
99
|
+
id, url: req.url(), method: req.method(),
|
|
100
|
+
resourceType: req.resourceType(),
|
|
101
|
+
startedAt: Date.now(),
|
|
102
|
+
postData: req.postDataBuffer()?.toString('base64'),
|
|
103
|
+
requestHeaders: req.headers(),
|
|
104
|
+
}
|
|
105
|
+
req._mcpId = id
|
|
106
|
+
networkRequests.push(entry)
|
|
107
|
+
networkById.set(id, entry)
|
|
108
|
+
if (networkRequests.length > 500) {
|
|
109
|
+
const dropped = networkRequests.splice(0, networkRequests.length - 500)
|
|
110
|
+
for (const d of dropped) networkById.delete(d.id)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
page.on('response', async resp => {
|
|
114
|
+
const req = resp.request()
|
|
115
|
+
const entry = networkById.get(req._mcpId)
|
|
116
|
+
if (!entry) return
|
|
117
|
+
entry.status = resp.status()
|
|
118
|
+
entry.responseHeaders = resp.headers()
|
|
119
|
+
entry.durationMs = Date.now() - entry.startedAt
|
|
120
|
+
entry.fromCache = resp.fromServiceWorker?.() ?? false
|
|
121
|
+
})
|
|
122
|
+
page.on('close', () => {
|
|
123
|
+
if (currentPage === page) currentPage = context?.pages().find(p => p !== page) ?? null
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function httpNewTab(url) {
|
|
128
|
+
const u = url ? `${cdpEndpoint}/json/new?${encodeURIComponent(url)}` : `${cdpEndpoint}/json/new`
|
|
129
|
+
// Try PUT then GET — different Chromium versions accept different methods.
|
|
130
|
+
let r = await fetch(u, { method: 'PUT' }).catch(() => null)
|
|
131
|
+
if (!r || !r.ok) r = await fetch(u).catch(() => null)
|
|
132
|
+
if (!r || !r.ok) throw new Error(`/json/new failed (${r?.status ?? 'no response'})`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function createBlankTab() {
|
|
136
|
+
const pagePromise = context.waitForEvent('page', { timeout: 10000 }).catch(() => null)
|
|
137
|
+
await httpNewTab()
|
|
138
|
+
const p = await pagePromise
|
|
139
|
+
if (p) return p
|
|
140
|
+
return context.pages().find(x => !x.isClosed()) ?? null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function getPage() {
|
|
144
|
+
if (bootstrapPromise) await bootstrapPromise
|
|
145
|
+
if (!browser || !browser.isConnected()) await (bootstrapPromise = connect().finally(() => { bootstrapPromise = null }))
|
|
146
|
+
if (!currentPage || currentPage.isClosed()) {
|
|
147
|
+
currentPage = context.pages().find(p => !p.isClosed()) ?? null
|
|
148
|
+
if (!currentPage) currentPage = await createBlankTab()
|
|
149
|
+
if (!currentPage) throw new Error('no usable page and could not create one')
|
|
150
|
+
}
|
|
151
|
+
return currentPage
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =========================================================================
|
|
155
|
+
// Tools
|
|
156
|
+
// =========================================================================
|
|
157
|
+
|
|
158
|
+
const TOOLS = []
|
|
159
|
+
const HANDLERS = {}
|
|
160
|
+
|
|
161
|
+
function tool(name, description, inputSchema, handler) {
|
|
162
|
+
TOOLS.push({ name, description, inputSchema })
|
|
163
|
+
HANDLERS[name] = handler
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sObj = (props = {}, required = []) => ({ type: 'object', properties: props, ...(required.length ? { required } : {}) })
|
|
167
|
+
const sStr = (description) => ({ type: 'string', ...(description ? { description } : {}) })
|
|
168
|
+
const sInt = (description, def) => ({ type: 'integer', ...(description ? { description } : {}), ...(def != null ? { default: def } : {}) })
|
|
169
|
+
const sBool = (description, def) => ({ type: 'boolean', ...(description ? { description } : {}), ...(def != null ? { default: def } : {}) })
|
|
170
|
+
const sNum = (description) => ({ type: 'number', ...(description ? { description } : {}) })
|
|
171
|
+
|
|
172
|
+
// -------------------- core navigation/state --------------------
|
|
173
|
+
|
|
174
|
+
tool('navigate', 'Navigate the current page to a URL. Waits for load by default.',
|
|
175
|
+
sObj({ url: sStr(), wait_until: sStr('load|domcontentloaded|networkidle, default load') }, ['url']),
|
|
176
|
+
async ({ url, wait_until }) => {
|
|
177
|
+
return withRetryOnNavigation(async () => {
|
|
178
|
+
const page = await getPage()
|
|
179
|
+
await page.goto(url, { waitUntil: wait_until ?? 'load', timeout: 30000 })
|
|
180
|
+
return `Navigated to ${page.url()}`
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
tool('navigate_back', 'Go back in history on the current page.',
|
|
185
|
+
sObj({}),
|
|
186
|
+
async () => { const page = await getPage(); await page.goBack({ waitUntil: 'commit', timeout: 5000 }).catch(() => {}); return `back → ${page.url()}` })
|
|
187
|
+
|
|
188
|
+
tool('navigate_forward', 'Go forward in history on the current page.',
|
|
189
|
+
sObj({}),
|
|
190
|
+
async () => { const page = await getPage(); await page.goForward({ waitUntil: 'commit', timeout: 5000 }).catch(() => {}); return `forward → ${page.url()}` })
|
|
191
|
+
|
|
192
|
+
tool('reload', 'Reload the current page.',
|
|
193
|
+
sObj({}),
|
|
194
|
+
async () => { const page = await getPage(); await page.reload(); return `reloaded ${page.url()}` })
|
|
195
|
+
|
|
196
|
+
tool('wait', 'Sleep for N milliseconds.',
|
|
197
|
+
sObj({ ms: sInt('milliseconds', 1000) }),
|
|
198
|
+
async ({ ms }) => { await new Promise(r => setTimeout(r, ms ?? 1000)); return `slept ${ms ?? 1000}ms` })
|
|
199
|
+
|
|
200
|
+
tool('wait_for', 'Wait for a condition: text to appear/disappear, selector to attach/detach, or time to elapse.',
|
|
201
|
+
sObj({
|
|
202
|
+
text: sStr('text to wait for (substring match in document)'),
|
|
203
|
+
text_gone: sStr('text to wait to disappear'),
|
|
204
|
+
selector: sStr('CSS selector to wait for'),
|
|
205
|
+
state: sStr('visible|hidden|attached|detached, default visible (used with selector)'),
|
|
206
|
+
time_ms: sInt('alternative: just sleep this long'),
|
|
207
|
+
timeout_ms: sInt('overall timeout, default 30000', 30000),
|
|
208
|
+
}),
|
|
209
|
+
async ({ text, text_gone, selector, state, time_ms, timeout_ms }) => {
|
|
210
|
+
const page = await getPage()
|
|
211
|
+
const t = timeout_ms ?? 30000
|
|
212
|
+
if (time_ms != null) { await page.waitForTimeout(time_ms); return `slept ${time_ms}ms` }
|
|
213
|
+
if (selector) { await page.waitForSelector(selector, { state: state ?? 'visible', timeout: t }); return `selector ${state ?? 'visible'}: ${selector}` }
|
|
214
|
+
if (text) { await page.waitForFunction(s => document.body.innerText.includes(s), text, { timeout: t }); return `text appeared: ${text}` }
|
|
215
|
+
if (text_gone) { await page.waitForFunction(s => !document.body.innerText.includes(s), text_gone, { timeout: t }); return `text gone: ${text_gone}` }
|
|
216
|
+
throw new Error('wait_for requires one of: text, text_gone, selector, time_ms')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
tool('evaluate', 'Evaluate JavaScript on the page. `expression` may be an expression or a `()=>...` function. Returns JSON.',
|
|
220
|
+
sObj({ expression: sStr() }, ['expression']),
|
|
221
|
+
async ({ expression }) => withRetryOnNavigation(async () => {
|
|
222
|
+
const page = await getPage()
|
|
223
|
+
const looksLikeFn = /^\s*(\([^)]*\)\s*=>|async\s|function\b)/.test(expression)
|
|
224
|
+
const r = await page.evaluate(looksLikeFn ? expression : `() => (${expression})`)
|
|
225
|
+
return JSON.stringify(r, null, 2)
|
|
226
|
+
}))
|
|
227
|
+
|
|
228
|
+
async function withRetryOnNavigation(fn) {
|
|
229
|
+
try { return await fn() }
|
|
230
|
+
catch (e) {
|
|
231
|
+
if (!/Execution context was destroyed|frame got detached|Target page, context or browser/i.test(e.message)) throw e
|
|
232
|
+
const page = await getPage()
|
|
233
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {})
|
|
234
|
+
return await fn()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
tool('get_text', 'Return document.body.innerText.',
|
|
239
|
+
sObj({}),
|
|
240
|
+
async () => withRetryOnNavigation(async () => (await getPage()).evaluate(() => document.body.innerText)))
|
|
241
|
+
|
|
242
|
+
tool('get_html', 'Return document.documentElement.outerHTML.',
|
|
243
|
+
sObj({}),
|
|
244
|
+
async () => withRetryOnNavigation(async () => (await getPage()).content()))
|
|
245
|
+
|
|
246
|
+
tool('screenshot', 'Take a PNG screenshot. If `path` given, save to disk; else return base64 image. Uses raw CDP to skip Playwright font-wait (which hangs on some ArkWeb pages).',
|
|
247
|
+
sObj({
|
|
248
|
+
path: sStr('save to this path instead of returning inline'),
|
|
249
|
+
full_page: sBool('capture full scrollable page', false),
|
|
250
|
+
selector: sStr('limit to an element matching this selector'),
|
|
251
|
+
}),
|
|
252
|
+
async ({ path, full_page, selector }) => {
|
|
253
|
+
const page = await getPage()
|
|
254
|
+
let buf
|
|
255
|
+
if (selector) {
|
|
256
|
+
buf = await page.locator(selector).first().screenshot({ type: 'png', timeout: 15000, animations: 'disabled' })
|
|
257
|
+
} else {
|
|
258
|
+
const session = await context.newCDPSession(page)
|
|
259
|
+
try {
|
|
260
|
+
const r = await session.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: !!full_page })
|
|
261
|
+
buf = Buffer.from(r.data, 'base64')
|
|
262
|
+
} finally { await session.detach().catch(() => {}) }
|
|
263
|
+
}
|
|
264
|
+
if (path) {
|
|
265
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
266
|
+
writeFileSync(path, buf)
|
|
267
|
+
return `saved ${buf.length} bytes to ${path}`
|
|
268
|
+
}
|
|
269
|
+
return { content: [{ type: 'image', data: buf.toString('base64'), mimeType: 'image/png' }] }
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// -------------------- tabs/pages --------------------
|
|
273
|
+
|
|
274
|
+
function pageId(p) {
|
|
275
|
+
// playwright doesn't expose target id; use index in context.pages() — caller passes 0..N-1 or a URL substring
|
|
276
|
+
return context.pages().indexOf(p)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
tool('list_pages', 'List all open pages in the context. Returns index (use as `id` for select_page), title, url.',
|
|
280
|
+
sObj({}),
|
|
281
|
+
async () => {
|
|
282
|
+
await getPage()
|
|
283
|
+
const pages = context.pages()
|
|
284
|
+
return JSON.stringify(await Promise.all(pages.map(async (p, i) => ({
|
|
285
|
+
id: i, title: await p.title().catch(() => ''), url: p.url(), current: p === currentPage,
|
|
286
|
+
}))), null, 2)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
tool('select_page', 'Switch the current page. `id` is the index from list_pages, OR a substring of the URL.',
|
|
290
|
+
sObj({ id: { oneOf: [{ type: 'integer' }, { type: 'string' }] } }, ['id']),
|
|
291
|
+
async ({ id }) => {
|
|
292
|
+
await getPage()
|
|
293
|
+
const pages = context.pages()
|
|
294
|
+
let target
|
|
295
|
+
if (typeof id === 'number') target = pages[id]
|
|
296
|
+
else target = pages.find(p => p.url().includes(id))
|
|
297
|
+
if (!target) throw new Error(`no page matching ${id}`)
|
|
298
|
+
currentPage = target
|
|
299
|
+
return `selected [${pages.indexOf(target)}] ${target.url()}`
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
tool('tab_new', 'Open a new tab. Optional `url` to navigate it.',
|
|
303
|
+
sObj({ url: sStr() }),
|
|
304
|
+
async ({ url }) => {
|
|
305
|
+
await getPage()
|
|
306
|
+
const pagePromise = context.waitForEvent('page', { timeout: 15000 }).catch(() => null)
|
|
307
|
+
await httpNewTab(url)
|
|
308
|
+
const p = await pagePromise
|
|
309
|
+
if (!p) return `created tab via CDP HTTP, but no playwright page event; url=${url ?? 'about:blank'}`
|
|
310
|
+
currentPage = p
|
|
311
|
+
if (url) await p.waitForLoadState('load', { timeout: 30000 }).catch(() => {})
|
|
312
|
+
return `opened tab [${context.pages().indexOf(p)}] ${p.url()}`
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
tool('tab_close', 'Close a tab by index (default: current).',
|
|
316
|
+
sObj({ id: sInt() }),
|
|
317
|
+
async ({ id }) => {
|
|
318
|
+
await getPage()
|
|
319
|
+
const pages = context.pages()
|
|
320
|
+
const target = (id == null) ? currentPage : pages[id]
|
|
321
|
+
if (!target) throw new Error(`no page at ${id}`)
|
|
322
|
+
const url = target.url()
|
|
323
|
+
await target.close()
|
|
324
|
+
return `closed ${url}`
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
tool('close', 'Disconnect from the browser. Does NOT kill the ArkWeb process.',
|
|
328
|
+
sObj({}),
|
|
329
|
+
async () => { await browser?.close(); browser = null; return 'disconnected' })
|
|
330
|
+
|
|
331
|
+
tool('resize', 'Set viewport size for the current page.',
|
|
332
|
+
sObj({ width: sInt(), height: sInt() }, ['width', 'height']),
|
|
333
|
+
async ({ width, height }) => {
|
|
334
|
+
const page = await getPage()
|
|
335
|
+
await page.setViewportSize({ width, height })
|
|
336
|
+
return `viewport ${width}x${height}`
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// -------------------- input --------------------
|
|
340
|
+
|
|
341
|
+
tool('click', 'Click an element matching a Playwright selector (CSS, text=, role=, etc).',
|
|
342
|
+
sObj({
|
|
343
|
+
selector: sStr(),
|
|
344
|
+
button: sStr('left|right|middle, default left'),
|
|
345
|
+
click_count: sInt('default 1'),
|
|
346
|
+
modifiers: { type: 'array', items: { type: 'string' }, description: 'Alt|Control|Meta|Shift' },
|
|
347
|
+
force: sBool('skip actionability checks'),
|
|
348
|
+
}, ['selector']),
|
|
349
|
+
async ({ selector, button, click_count, modifiers, force }) => {
|
|
350
|
+
const page = await getPage()
|
|
351
|
+
await page.locator(selector).first().click({ button, clickCount: click_count, modifiers, force, timeout: 15000 })
|
|
352
|
+
return `clicked ${selector}`
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
tool('hover', 'Hover an element matching a selector.',
|
|
356
|
+
sObj({ selector: sStr() }, ['selector']),
|
|
357
|
+
async ({ selector }) => {
|
|
358
|
+
const page = await getPage()
|
|
359
|
+
await page.locator(selector).first().hover({ timeout: 15000 })
|
|
360
|
+
return `hovered ${selector}`
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
tool('type', 'Type text into a focused-or-selectored element character-by-character (slower; fires keyboard events).',
|
|
364
|
+
sObj({ selector: sStr(), text: sStr(), delay_ms: sInt('per-char delay', 0) }, ['selector', 'text']),
|
|
365
|
+
async ({ selector, text, delay_ms }) => {
|
|
366
|
+
const page = await getPage()
|
|
367
|
+
await page.locator(selector).first().pressSequentially(text, { delay: delay_ms ?? 0, timeout: 15000 })
|
|
368
|
+
return `typed ${text.length} chars into ${selector}`
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
tool('fill', 'Set an input/textarea/contenteditable to the given value (fast; clears existing).',
|
|
372
|
+
sObj({ selector: sStr(), value: sStr() }, ['selector', 'value']),
|
|
373
|
+
async ({ selector, value }) => {
|
|
374
|
+
const page = await getPage()
|
|
375
|
+
await page.locator(selector).first().fill(value, { timeout: 15000 })
|
|
376
|
+
return `filled ${selector}`
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
tool('fill_form', 'Fill multiple fields at once. `fields`: [{selector, value}].',
|
|
380
|
+
sObj({ fields: { type: 'array', items: { type: 'object', properties: { selector: sStr(), value: sStr() }, required: ['selector', 'value'] } } }, ['fields']),
|
|
381
|
+
async ({ fields }) => {
|
|
382
|
+
const page = await getPage()
|
|
383
|
+
for (const f of fields) await page.locator(f.selector).first().fill(f.value, { timeout: 15000 })
|
|
384
|
+
return `filled ${fields.length} field(s)`
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
tool('press_key', 'Press a key (Enter, Tab, ArrowDown, etc). Optional selector to focus first.',
|
|
388
|
+
sObj({ key: sStr(), selector: sStr() }, ['key']),
|
|
389
|
+
async ({ key, selector }) => {
|
|
390
|
+
const page = await getPage()
|
|
391
|
+
if (selector) await page.locator(selector).first().press(key, { timeout: 15000 })
|
|
392
|
+
else await page.keyboard.press(key)
|
|
393
|
+
return `pressed ${key}`
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
tool('select_option', 'Select <option>(s) in a <select>. `values` accepts strings (value attr), or {label}/{index}.',
|
|
397
|
+
sObj({ selector: sStr(), values: { type: 'array', items: { type: ['string', 'object'] } } }, ['selector', 'values']),
|
|
398
|
+
async ({ selector, values }) => {
|
|
399
|
+
const page = await getPage()
|
|
400
|
+
const r = await page.locator(selector).first().selectOption(values, { timeout: 15000 })
|
|
401
|
+
return `selected ${JSON.stringify(r)}`
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
tool('file_upload', 'Set files on a <input type="file">. `paths` are absolute paths on the host.',
|
|
405
|
+
sObj({ selector: sStr(), paths: { type: 'array', items: { type: 'string' } } }, ['selector', 'paths']),
|
|
406
|
+
async ({ selector, paths }) => {
|
|
407
|
+
const page = await getPage()
|
|
408
|
+
await page.locator(selector).first().setInputFiles(paths, { timeout: 15000 })
|
|
409
|
+
return `uploaded ${paths.length} file(s)`
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
tool('drag', 'Drag from one selector to another.',
|
|
413
|
+
sObj({ from: sStr(), to: sStr() }, ['from', 'to']),
|
|
414
|
+
async ({ from, to }) => {
|
|
415
|
+
const page = await getPage()
|
|
416
|
+
await page.locator(from).first().dragTo(page.locator(to).first(), { timeout: 15000 })
|
|
417
|
+
return `dragged ${from} → ${to}`
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// -------------------- low-level mouse --------------------
|
|
421
|
+
|
|
422
|
+
tool('mouse_move_xy', 'Move mouse to (x, y).',
|
|
423
|
+
sObj({ x: sNum(), y: sNum(), steps: sInt('intermediate steps', 1) }, ['x', 'y']),
|
|
424
|
+
async ({ x, y, steps }) => { const p = await getPage(); await p.mouse.move(x, y, { steps: steps ?? 1 }); return `moved to ${x},${y}` })
|
|
425
|
+
|
|
426
|
+
tool('mouse_click_xy', 'Click at (x, y).',
|
|
427
|
+
sObj({ x: sNum(), y: sNum(), button: sStr('left|right|middle, default left') }, ['x', 'y']),
|
|
428
|
+
async ({ x, y, button }) => { const p = await getPage(); await p.mouse.click(x, y, { button }); return `clicked ${x},${y}` })
|
|
429
|
+
|
|
430
|
+
tool('mouse_down', 'Press a mouse button at current position.',
|
|
431
|
+
sObj({ button: sStr('left|right|middle, default left') }),
|
|
432
|
+
async ({ button }) => { const p = await getPage(); await p.mouse.down({ button }); return `mouse down` })
|
|
433
|
+
|
|
434
|
+
tool('mouse_up', 'Release a mouse button.',
|
|
435
|
+
sObj({ button: sStr('left|right|middle, default left') }),
|
|
436
|
+
async ({ button }) => { const p = await getPage(); await p.mouse.up({ button }); return `mouse up` })
|
|
437
|
+
|
|
438
|
+
tool('mouse_drag_xy', 'Drag from (x1,y1) to (x2,y2).',
|
|
439
|
+
sObj({ x1: sNum(), y1: sNum(), x2: sNum(), y2: sNum(), steps: sInt('default 10', 10) }, ['x1', 'y1', 'x2', 'y2']),
|
|
440
|
+
async ({ x1, y1, x2, y2, steps }) => {
|
|
441
|
+
const p = await getPage()
|
|
442
|
+
await p.mouse.move(x1, y1)
|
|
443
|
+
await p.mouse.down()
|
|
444
|
+
await p.mouse.move(x2, y2, { steps: steps ?? 10 })
|
|
445
|
+
await p.mouse.up()
|
|
446
|
+
return `dragged (${x1},${y1}) → (${x2},${y2})`
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
tool('mouse_wheel', 'Scroll by (deltaX, deltaY).',
|
|
450
|
+
sObj({ delta_x: sNum(), delta_y: sNum() }, ['delta_x', 'delta_y']),
|
|
451
|
+
async ({ delta_x, delta_y }) => { const p = await getPage(); await p.mouse.wheel(delta_x, delta_y); return `wheel (${delta_x},${delta_y})` })
|
|
452
|
+
|
|
453
|
+
// -------------------- snapshot / locator --------------------
|
|
454
|
+
|
|
455
|
+
tool('snapshot', 'Return an accessibility snapshot of the current page as flat [role, name, value] rows.',
|
|
456
|
+
sObj({ interactive_only: sBool('only button/link/textbox/etc', true) }),
|
|
457
|
+
async ({ interactive_only }) => {
|
|
458
|
+
const page = await getPage()
|
|
459
|
+
const session = await context.newCDPSession(page)
|
|
460
|
+
try {
|
|
461
|
+
await session.send('Accessibility.enable').catch(() => {})
|
|
462
|
+
const { nodes } = await session.send('Accessibility.getFullAXTree', {})
|
|
463
|
+
const interactiveRoles = new Set(['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'menuitem', 'tab', 'searchbox', 'switch', 'slider'])
|
|
464
|
+
const onlyI = interactive_only !== false
|
|
465
|
+
const rows = []
|
|
466
|
+
for (const n of nodes) {
|
|
467
|
+
const role = n.role?.value; const name = n.name?.value?.trim(); const value = n.value?.value
|
|
468
|
+
if (!role || role === 'none' || role === 'generic') continue
|
|
469
|
+
if (onlyI && !interactiveRoles.has(role)) continue
|
|
470
|
+
if (!name && !value) continue
|
|
471
|
+
rows.push({ role, name: name || undefined, value: value || undefined })
|
|
472
|
+
}
|
|
473
|
+
return JSON.stringify(rows, null, 2)
|
|
474
|
+
} finally { await session.detach().catch(() => {}) }
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// -------------------- console / dialogs --------------------
|
|
478
|
+
|
|
479
|
+
tool('console_messages', 'Return buffered console messages (and pageerror) from all pages. Pass `clear: true` to drain.',
|
|
480
|
+
sObj({ clear: sBool(), limit: sInt() }),
|
|
481
|
+
async ({ clear, limit }) => {
|
|
482
|
+
await getPage()
|
|
483
|
+
const out = limit ? consoleBuffer.slice(-limit) : consoleBuffer.slice()
|
|
484
|
+
if (clear) consoleBuffer.length = 0
|
|
485
|
+
return JSON.stringify(out, null, 2)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
tool('handle_dialog', 'Set the default action for future JS dialogs (alert/confirm/prompt).',
|
|
489
|
+
sObj({ action: sStr('accept|dismiss, default dismiss'), prompt_text: sStr() }),
|
|
490
|
+
async ({ action, prompt_text }) => {
|
|
491
|
+
dialogPolicy = { action: action ?? 'dismiss', promptText: prompt_text ?? '' }
|
|
492
|
+
return `dialogs will be ${dialogPolicy.action}${dialogPolicy.promptText ? ` with "${dialogPolicy.promptText}"` : ''}`
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// -------------------- network --------------------
|
|
496
|
+
|
|
497
|
+
tool('network_requests', 'Return buffered network requests (last 500). Filter with `url_contains`, `method`, `status_min`, `since_ms`.',
|
|
498
|
+
sObj({ url_contains: sStr(), method: sStr(), status_min: sInt(), since_ms: sInt(), clear: sBool() }),
|
|
499
|
+
async ({ url_contains, method, status_min, since_ms, clear }) => {
|
|
500
|
+
await getPage()
|
|
501
|
+
const cutoff = since_ms ? Date.now() - since_ms : 0
|
|
502
|
+
let out = networkRequests
|
|
503
|
+
if (url_contains) out = out.filter(r => r.url.includes(url_contains))
|
|
504
|
+
if (method) out = out.filter(r => r.method === method.toUpperCase())
|
|
505
|
+
if (status_min != null) out = out.filter(r => (r.status ?? 0) >= status_min)
|
|
506
|
+
if (since_ms) out = out.filter(r => r.startedAt >= cutoff)
|
|
507
|
+
const result = out.map(({ id, url, method, status, resourceType, durationMs }) => ({ id, url, method, status, resourceType, durationMs }))
|
|
508
|
+
if (clear) { networkRequests.length = 0; networkById.clear() }
|
|
509
|
+
return JSON.stringify(result, null, 2)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
tool('network_request', 'Return full detail (headers, post body) for a single buffered request by id.',
|
|
513
|
+
sObj({ id: sStr() }, ['id']),
|
|
514
|
+
async ({ id }) => {
|
|
515
|
+
await getPage()
|
|
516
|
+
const r = networkById.get(id)
|
|
517
|
+
if (!r) throw new Error(`no request with id ${id}`)
|
|
518
|
+
return JSON.stringify(r, null, 2)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
tool('network_state_set', 'Set offline mode for the context.',
|
|
522
|
+
sObj({ offline: sBool() }, ['offline']),
|
|
523
|
+
async ({ offline }) => {
|
|
524
|
+
await getPage()
|
|
525
|
+
await context.setOffline(offline)
|
|
526
|
+
return `offline=${offline}`
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
tool('route', 'Intercept requests matching a glob (e.g. **/*.png). `action`: abort|continue|fulfill. If fulfill, provide `body`/`status`/`headers`.',
|
|
530
|
+
sObj({
|
|
531
|
+
pattern: sStr('URL glob: **/*.png, https://api.example.com/**, etc'),
|
|
532
|
+
action: sStr('abort|continue|fulfill'),
|
|
533
|
+
body: sStr('response body (for fulfill)'),
|
|
534
|
+
status: sInt('response status (for fulfill, default 200)'),
|
|
535
|
+
headers: { type: 'object' },
|
|
536
|
+
}, ['pattern', 'action']),
|
|
537
|
+
async ({ pattern, action, body, status, headers }) => {
|
|
538
|
+
const page = await getPage()
|
|
539
|
+
if (routeHandlers.has(pattern)) await page.unroute(pattern, routeHandlers.get(pattern).handler)
|
|
540
|
+
const state = { hits: 0, action }
|
|
541
|
+
const handler = async route => {
|
|
542
|
+
state.hits++
|
|
543
|
+
if (action === 'abort') return route.abort()
|
|
544
|
+
if (action === 'fulfill') return route.fulfill({ status: status ?? 200, body: body ?? '', headers: headers ?? {} })
|
|
545
|
+
return route.continue()
|
|
546
|
+
}
|
|
547
|
+
state.handler = handler
|
|
548
|
+
routeHandlers.set(pattern, state)
|
|
549
|
+
await page.route(pattern, handler)
|
|
550
|
+
return `route registered: ${pattern} → ${action}`
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
tool('route_list', 'List active route patterns and their hit counts.',
|
|
554
|
+
sObj({}),
|
|
555
|
+
async () => {
|
|
556
|
+
await getPage()
|
|
557
|
+
return JSON.stringify([...routeHandlers.entries()].map(([pattern, s]) => ({ pattern, action: s.action, hits: s.hits })), null, 2)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
tool('unroute', 'Remove a route pattern.',
|
|
561
|
+
sObj({ pattern: sStr() }, ['pattern']),
|
|
562
|
+
async ({ pattern }) => {
|
|
563
|
+
const page = await getPage()
|
|
564
|
+
const s = routeHandlers.get(pattern)
|
|
565
|
+
if (!s) throw new Error(`no route ${pattern}`)
|
|
566
|
+
await page.unroute(pattern, s.handler)
|
|
567
|
+
routeHandlers.delete(pattern)
|
|
568
|
+
return `unrouted ${pattern}`
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// -------------------- cookies --------------------
|
|
572
|
+
|
|
573
|
+
tool('cookie_list', 'List cookies (optionally filtered by `urls`).',
|
|
574
|
+
sObj({ urls: { type: 'array', items: { type: 'string' } } }),
|
|
575
|
+
async ({ urls }) => { await getPage(); return JSON.stringify(await context.cookies(urls), null, 2) })
|
|
576
|
+
|
|
577
|
+
tool('cookie_get', 'Get cookies by name (returns matching entries).',
|
|
578
|
+
sObj({ name: sStr() }, ['name']),
|
|
579
|
+
async ({ name }) => { await getPage(); return JSON.stringify((await context.cookies()).filter(c => c.name === name), null, 2) })
|
|
580
|
+
|
|
581
|
+
tool('cookie_set', 'Add cookies. Each: {name, value, url?, domain?, path?, expires?, httpOnly?, secure?, sameSite?}.',
|
|
582
|
+
sObj({ cookies: { type: 'array', items: { type: 'object' } } }, ['cookies']),
|
|
583
|
+
async ({ cookies }) => { await getPage(); await context.addCookies(cookies); return `added ${cookies.length} cookie(s)` })
|
|
584
|
+
|
|
585
|
+
tool('cookie_delete', 'Delete a cookie by name (and optional domain/path).',
|
|
586
|
+
sObj({ name: sStr(), domain: sStr(), path: sStr() }, ['name']),
|
|
587
|
+
async ({ name, domain, path }) => {
|
|
588
|
+
await getPage()
|
|
589
|
+
const all = await context.cookies()
|
|
590
|
+
const keep = all.filter(c => !(c.name === name && (!domain || c.domain === domain) && (!path || c.path === path)))
|
|
591
|
+
await context.clearCookies()
|
|
592
|
+
await context.addCookies(keep)
|
|
593
|
+
return `deleted; ${all.length - keep.length} removed`
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
tool('cookie_clear', 'Clear all cookies from the context.',
|
|
597
|
+
sObj({}),
|
|
598
|
+
async () => { await getPage(); await context.clearCookies(); return 'cleared' })
|
|
599
|
+
|
|
600
|
+
// -------------------- storage (per-origin) --------------------
|
|
601
|
+
|
|
602
|
+
function originExpr(kind, op, args = {}) {
|
|
603
|
+
if (op === 'list') return `Object.fromEntries(Object.keys(${kind}).map(k => [k, ${kind}.getItem(k)]))`
|
|
604
|
+
if (op === 'get') return `${kind}.getItem(${JSON.stringify(args.key)})`
|
|
605
|
+
if (op === 'set') return `${kind}.setItem(${JSON.stringify(args.key)}, ${JSON.stringify(args.value)})`
|
|
606
|
+
if (op === 'delete') return `${kind}.removeItem(${JSON.stringify(args.key)})`
|
|
607
|
+
if (op === 'clear') return `${kind}.clear()`
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const kind of ['localStorage', 'sessionStorage']) {
|
|
611
|
+
const prefix = kind === 'localStorage' ? 'localstorage' : 'sessionstorage'
|
|
612
|
+
tool(`${prefix}_list`, `List all ${kind} entries (current page's origin).`, sObj({}),
|
|
613
|
+
async () => (await getPage()).evaluate(`(() => ${originExpr(kind, 'list')})()`))
|
|
614
|
+
tool(`${prefix}_get`, `Get a ${kind} value.`, sObj({ key: sStr() }, ['key']),
|
|
615
|
+
async ({ key }) => (await getPage()).evaluate(`${kind}.getItem(${JSON.stringify(key)})`))
|
|
616
|
+
tool(`${prefix}_set`, `Set a ${kind} value.`, sObj({ key: sStr(), value: sStr() }, ['key', 'value']),
|
|
617
|
+
async ({ key, value }) => { await (await getPage()).evaluate(`${kind}.setItem(${JSON.stringify(key)},${JSON.stringify(value)})`); return `set ${key}` })
|
|
618
|
+
tool(`${prefix}_delete`, `Delete a ${kind} key.`, sObj({ key: sStr() }, ['key']),
|
|
619
|
+
async ({ key }) => { await (await getPage()).evaluate(`${kind}.removeItem(${JSON.stringify(key)})`); return `deleted ${key}` })
|
|
620
|
+
tool(`${prefix}_clear`, `Clear all ${kind} for current origin.`, sObj({}),
|
|
621
|
+
async () => { await (await getPage()).evaluate(`${kind}.clear()`); return 'cleared' })
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
tool('storage_state', 'Dump full storage state (cookies + localStorage). If `path` given, save to file.',
|
|
625
|
+
sObj({ path: sStr() }),
|
|
626
|
+
async ({ path }) => {
|
|
627
|
+
await getPage()
|
|
628
|
+
const state = await context.storageState({ path })
|
|
629
|
+
return path ? `saved to ${path}` : JSON.stringify(state, null, 2)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// -------------------- highlight (Overlay) --------------------
|
|
633
|
+
|
|
634
|
+
const highlighted = new Set() // selectors currently shown
|
|
635
|
+
|
|
636
|
+
tool('highlight', 'Draw a colored outline around all elements matching a selector (page-side overlay, not Chrome devtools overlay).',
|
|
637
|
+
sObj({ selector: sStr(), color: sStr('CSS color, default red'), label: sStr() }, ['selector']),
|
|
638
|
+
async ({ selector, color, label }) => {
|
|
639
|
+
const page = await getPage()
|
|
640
|
+
await page.evaluate(({ sel, col, lbl }) => {
|
|
641
|
+
const STYLE_ID = '__arkweb_mcp_hl__'
|
|
642
|
+
let style = document.getElementById(STYLE_ID)
|
|
643
|
+
if (!style) { style = document.createElement('style'); style.id = STYLE_ID; document.head.appendChild(style) }
|
|
644
|
+
const cls = '__arkweb_hl_' + Math.abs([...sel].reduce((a, c) => a * 31 + c.charCodeAt(0), 0)).toString(36)
|
|
645
|
+
style.appendChild(document.createTextNode(`.${cls}{outline:2px solid ${col} !important;outline-offset:1px}.${cls}::before{content:${JSON.stringify(lbl || sel)};position:absolute;background:${col};color:#fff;font:11px monospace;padding:1px 4px;z-index:99999}`))
|
|
646
|
+
for (const el of document.querySelectorAll(sel)) el.classList.add(cls)
|
|
647
|
+
return cls
|
|
648
|
+
}, { sel: selector, col: color ?? 'red', lbl: label ?? '' })
|
|
649
|
+
highlighted.add(selector)
|
|
650
|
+
return `highlighted ${selector}`
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
tool('hide_highlight', 'Remove all overlays added by `highlight`.',
|
|
654
|
+
sObj({}),
|
|
655
|
+
async () => {
|
|
656
|
+
const page = await getPage()
|
|
657
|
+
await page.evaluate(() => {
|
|
658
|
+
document.getElementById('__arkweb_mcp_hl__')?.remove()
|
|
659
|
+
for (const el of document.querySelectorAll('[class*="__arkweb_hl_"]')) {
|
|
660
|
+
for (const c of [...el.classList]) if (c.startsWith('__arkweb_hl_')) el.classList.remove(c)
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
highlighted.clear()
|
|
664
|
+
return 'cleared'
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
// -------------------- PDF / tracing --------------------
|
|
668
|
+
|
|
669
|
+
tool('pdf_save', 'Save the current page as PDF (headless-only in Chrome — may not work on ArkWeb foreground).',
|
|
670
|
+
sObj({ path: sStr() }, ['path']),
|
|
671
|
+
async ({ path }) => {
|
|
672
|
+
const page = await getPage()
|
|
673
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
674
|
+
await page.pdf({ path })
|
|
675
|
+
return `saved PDF to ${path}`
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
tool('start_tracing', 'Begin a Playwright trace. Stop with stop_tracing.',
|
|
679
|
+
sObj({ snapshots: sBool('record DOM snapshots', true), screenshots: sBool('record screenshots', false), name: sStr() }),
|
|
680
|
+
async ({ snapshots, screenshots, name }) => {
|
|
681
|
+
await getPage()
|
|
682
|
+
await context.tracing.start({ snapshots: snapshots !== false, screenshots: !!screenshots, name })
|
|
683
|
+
return 'tracing started'
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
tool('stop_tracing', 'Stop tracing and save the .zip to `path`.',
|
|
687
|
+
sObj({ path: sStr() }, ['path']),
|
|
688
|
+
async ({ path }) => {
|
|
689
|
+
await getPage()
|
|
690
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
691
|
+
await context.tracing.stop({ path })
|
|
692
|
+
return `trace saved to ${path}`
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// =========================================================================
|
|
696
|
+
// MCP stdio framing
|
|
697
|
+
// =========================================================================
|
|
698
|
+
|
|
699
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n') }
|
|
700
|
+
|
|
701
|
+
async function handle(msg) {
|
|
702
|
+
try {
|
|
703
|
+
switch (msg.method) {
|
|
704
|
+
case 'initialize':
|
|
705
|
+
return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'arkweb-cdp-mcp', version: '0.2.0' } }
|
|
706
|
+
case 'notifications/initialized':
|
|
707
|
+
return null
|
|
708
|
+
case 'tools/list':
|
|
709
|
+
return { tools: TOOLS }
|
|
710
|
+
case 'tools/call': {
|
|
711
|
+
const h = HANDLERS[msg.params.name]
|
|
712
|
+
if (!h) throw new Error(`unknown tool: ${msg.params.name}`)
|
|
713
|
+
const result = await h(msg.params.arguments ?? {})
|
|
714
|
+
if (result && typeof result === 'object' && result.content) return result
|
|
715
|
+
return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }
|
|
716
|
+
}
|
|
717
|
+
case 'ping':
|
|
718
|
+
return {}
|
|
719
|
+
default:
|
|
720
|
+
throw { code: -32601, message: `unknown method: ${msg.method}` }
|
|
721
|
+
}
|
|
722
|
+
} catch (e) {
|
|
723
|
+
throw e?.code ? e : { code: -32603, message: e.message || String(e) }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
process.on('unhandledRejection', e => log('unhandledRejection:', e?.message || e))
|
|
728
|
+
process.on('uncaughtException', e => log('uncaughtException:', e?.message || e))
|
|
729
|
+
|
|
730
|
+
let buf = ''
|
|
731
|
+
process.stdin.on('data', chunk => {
|
|
732
|
+
buf += chunk
|
|
733
|
+
let i
|
|
734
|
+
while ((i = buf.indexOf('\n')) !== -1) {
|
|
735
|
+
const line = buf.slice(0, i).trim()
|
|
736
|
+
buf = buf.slice(i + 1)
|
|
737
|
+
if (!line) continue
|
|
738
|
+
let msg
|
|
739
|
+
try { msg = JSON.parse(line) } catch { log('bad json:', line); continue }
|
|
740
|
+
handle(msg).then(result => {
|
|
741
|
+
if (msg.id === undefined || result === null) return
|
|
742
|
+
send({ jsonrpc: '2.0', id: msg.id, result })
|
|
743
|
+
}).catch(err => {
|
|
744
|
+
if (msg.id === undefined) return
|
|
745
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: err.code ?? -32603, message: err.message ?? String(err) } })
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
process.stdin.on('end', () => process.exit(0))
|
|
751
|
+
|
|
752
|
+
bootstrapPromise = connect().catch(e => { log('bootstrap failed:', e.message); process.exit(1) }).finally(() => { bootstrapPromise = null })
|