pointclick 0.1.0__py3-none-any.whl

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.
pointclick/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """pointclick: a browser MCP server that lists what you can click, by number."""
pointclick/server.py ADDED
@@ -0,0 +1,421 @@
1
+ """pointclick: jev's compact indexed snapshot for the fast path, Playwright for everything else."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import os
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+
10
+ from mcp.server.mcpserver import Image, MCPServer
11
+ from playwright.async_api import Error as PWError
12
+ from playwright.async_api import TimeoutError as PWTimeout
13
+ from playwright.async_api import async_playwright
14
+
15
+ SNAPSHOT = (Path(__file__).parent / "snapshot.js").read_text()
16
+ OPS = {"click": "CLICK", "fill": "TYPE", "select": "SELECT"}
17
+ VALUE_ROLES = {"textbox", "searchbox", "combobox", "spinbutton"}
18
+ FINGERPRINT = (
19
+ "() => [location.href, document.title, document.body?.innerText.length,"
20
+ " document.querySelectorAll('a,button,input,select,textarea,[role]').length].join('|')"
21
+ )
22
+ # Two animation frames, or 150 ms, whichever first.
23
+ SETTLE = (
24
+ "() => new Promise(r => { let n = 0; const f = () => ++n >= 2 ? r() : requestAnimationFrame(f);"
25
+ " requestAnimationFrame(f); setTimeout(r, 150); })"
26
+ )
27
+
28
+ # After an action: done once the DOM has been still for QUIET s and no request younger than YOUNG s is
29
+ # pending, or after CAP s. Older requests (long-polls, streams) don't hold it up.
30
+ QUIET, YOUNG, CAP = 0.15, 0.5, 2.0
31
+ IGNORED_REQUESTS = {"image", "media", "font"}
32
+
33
+ mcp = MCPServer("pointclick", version=version("pointclick"))
34
+ S = {
35
+ "pw": None,
36
+ "browser": None,
37
+ "ctx": None,
38
+ "page": None,
39
+ "index": {},
40
+ "console": [],
41
+ "fp": None,
42
+ "notes": [],
43
+ "inflight": {},
44
+ }
45
+
46
+
47
+ # Clients may send tool calls in parallel; one browser and one current page serve them in turn.
48
+ _lock = asyncio.Lock()
49
+
50
+
51
+ def _one_at_a_time(tool):
52
+ @functools.wraps(tool)
53
+ async def run(*args, **kwargs):
54
+ async with _lock:
55
+ return await tool(*args, **kwargs)
56
+
57
+ return run
58
+
59
+
60
+ def _track(page):
61
+ # Popups (OAuth, new tabs) become the current page.
62
+ S["page"] = page
63
+ page.on("console", lambda m: _log(f"[{m.type}] {m.text}"))
64
+ page.on("pageerror", lambda e: _log(f"[pageerror] {e}"))
65
+ page.on("request", _request_started)
66
+ page.on("requestfinished", _request_done)
67
+ page.on("requestfailed", _request_done)
68
+
69
+
70
+ def _request_started(request):
71
+ if request.resource_type not in IGNORED_REQUESTS:
72
+ S["inflight"][request] = asyncio.get_running_loop().time()
73
+
74
+
75
+ def _request_done(request):
76
+ S["inflight"].pop(request, None)
77
+
78
+
79
+ def _log(line):
80
+ S["console"].append(line)
81
+ del S["console"][:-300]
82
+
83
+
84
+ async def _launch(pw):
85
+ # Installed Chrome first; Playwright's bundled Chromium when it's missing.
86
+ headless = os.environ.get("HEADED") != "1"
87
+ channel = os.environ.get("BROWSER_CHANNEL", "chrome")
88
+ if channel == "chromium":
89
+ return await pw.chromium.launch(headless=headless)
90
+ try:
91
+ return await pw.chromium.launch(channel=channel, headless=headless)
92
+ except PWError as e:
93
+ try:
94
+ return await pw.chromium.launch(headless=headless)
95
+ except PWError as e2:
96
+ raise RuntimeError(
97
+ f"No browser. {channel!r}: {str(e).splitlines()[0]} Bundled Chromium: {str(e2).splitlines()[0]} "
98
+ "Install Chrome, or run: playwright install chromium"
99
+ ) from None
100
+
101
+
102
+ async def _page():
103
+ if S["page"] and not S["page"].is_closed():
104
+ return S["page"]
105
+ if not S["pw"]:
106
+ S["pw"] = await async_playwright().start()
107
+ S["browser"] = await _launch(S["pw"])
108
+ S["ctx"] = await S["browser"].new_context(viewport={"width": 1280, "height": 800})
109
+ S["ctx"].on("page", _track)
110
+ open_pages = [p for p in S["ctx"].pages if not p.is_closed()]
111
+ if open_pages:
112
+ S["page"] = open_pages[-1]
113
+ return S["page"]
114
+ return await S["ctx"].new_page()
115
+
116
+
117
+ async def _settle(page):
118
+ try:
119
+ await page.wait_for_load_state("domcontentloaded", timeout=5000)
120
+ await page.evaluate(SETTLE)
121
+ except (PWError, PWTimeout):
122
+ pass
123
+
124
+
125
+ async def _frame_snapshot(frame, full):
126
+ if frame != frame.page.main_frame:
127
+ box = await (await frame.frame_element()).bounding_box()
128
+ if not box or not box["width"] or not box["height"]:
129
+ return None
130
+ vp = frame.page.viewport_size
131
+ if not full and (box["y"] >= vp["height"] or box["y"] + box["height"] <= 0):
132
+ return None
133
+ return await frame.evaluate(SNAPSHOT, full)
134
+
135
+
136
+ async def _snapshot(full):
137
+ page = await _page()
138
+ for attempt in range(40):
139
+ try:
140
+ frames = []
141
+ for frame in page.frames:
142
+ if frame.is_detached():
143
+ continue
144
+ try:
145
+ snap = await _frame_snapshot(frame, full)
146
+ except PWError:
147
+ if frame == page.main_frame:
148
+ raise
149
+ continue
150
+ if snap:
151
+ frames.append((frame, snap))
152
+ if frames and frames[0][0] == page.main_frame:
153
+ return page, frames
154
+ except PWError:
155
+ pass
156
+ await asyncio.sleep(0.05)
157
+ raise RuntimeError("Page never settled")
158
+
159
+
160
+ def _norm(label):
161
+ return " ".join(label.split())
162
+
163
+
164
+ def _render(page, frames, full):
165
+ S["index"] = {}
166
+ main = frames[0][1]
167
+ lines = [f"url: {main['url']}", f"title: {main['title']}"]
168
+ n = 0
169
+ for frame, snap in frames:
170
+ if frame != page.main_frame:
171
+ lines.append(f"-- frame {snap['url'][:80]} --")
172
+ nodes = {}
173
+ for a in snap["actions"]:
174
+ if a["kind"] not in OPS:
175
+ continue
176
+ e = nodes.get(a["node"])
177
+ if e is None:
178
+ n += 1
179
+ label = _norm(a["label"].split(" → ")[0])
180
+ e = nodes[a["node"]] = {"i": str(n), "a": a, "label": label, "ops": [], "options": []}
181
+ S["index"][e["i"]] = {"frame": frame, "node": a["node"], "label": label, "options": e["options"]}
182
+ if OPS[a["kind"]] not in e["ops"]:
183
+ e["ops"].append(OPS[a["kind"]])
184
+ if a["kind"] == "select":
185
+ e["options"].append((a["value"], _norm(a["label"].split(" → ", 1)[-1])))
186
+ for e in nodes.values():
187
+ a = e["a"]
188
+ bits = [f"[{e['i']}]", a["role"], repr(e["label"][:120])]
189
+ value = a.get("current_value") if a["kind"] == "select" else a.get("value")
190
+ if value and a["role"] in VALUE_ROLES:
191
+ bits.append(f"value={value[:80]!r}")
192
+ bits += [k for k in ("checked", "selected", "expanded") if a.get(k) == "true"]
193
+ bits.append(f"<{','.join(e['ops'])}>")
194
+ lines.append(" ".join(bits))
195
+ for k, (_, label) in enumerate(e["options"], 1):
196
+ lines.append(f" [{e['i']}:{k}] {label}")
197
+ if snap.get("omitted_actions"):
198
+ lines.append(f"({snap['omitted_actions']} more elements omitted)")
199
+ scroll = main["scroll"]
200
+ if not full and scroll["y"] + main["h"] < scroll["height"] - 2:
201
+ lines.append(f"(more below: {scroll['height'] - scroll['y'] - main['h']}px; SCROLL_DOWN or observe(full=true))")
202
+ text = (main.get("text") or "").strip()
203
+ if text:
204
+ lines += ["", "text:", text[: 4000 if full else 1500]]
205
+ return "\n".join(lines)
206
+
207
+
208
+ async def _fingerprint(page):
209
+ try:
210
+ return await page.evaluate(FINGERPRINT)
211
+ except PWError:
212
+ return None
213
+
214
+
215
+ async def _observe(full=False):
216
+ page, frames = await _snapshot(full)
217
+ S["fp"] = await _fingerprint(page)
218
+ return _render(page, frames, full)
219
+
220
+
221
+ async def _quiet(page):
222
+ loop = asyncio.get_running_loop()
223
+ deadline = loop.time() + CAP
224
+ last, changed = await _fingerprint(page), loop.time()
225
+ while loop.time() < deadline:
226
+ await asyncio.sleep(0.05)
227
+ fp, now = await _fingerprint(page), loop.time()
228
+ if fp != last:
229
+ last, changed = fp, now
230
+ elif now - changed >= QUIET and not any(now - t < YOUNG for t in S["inflight"].values()):
231
+ return
232
+
233
+
234
+ async def _wait_for_change(page, limit=3.0):
235
+ # Against the last observe, so a change that already landed returns at once.
236
+ deadline = asyncio.get_running_loop().time() + limit
237
+ while asyncio.get_running_loop().time() < deadline:
238
+ if await _fingerprint(page) != S["fp"]:
239
+ return
240
+ await asyncio.sleep(0.1)
241
+
242
+
243
+ class Stale(Exception):
244
+ pass
245
+
246
+
247
+ async def _resolve(target):
248
+ """Index from the last observe ("3", or "3:2" for a select option), or any Playwright selector."""
249
+ page = await _page()
250
+ idx, _, opt = target.partition(":")
251
+ if idx.isdigit() and (not opt or opt.isdigit()):
252
+ entry = S["index"].get(idx)
253
+ if not entry:
254
+ raise Stale(f"No element [{idx}] in the last observe.")
255
+ el = await _element(entry)
256
+ if el is None:
257
+ # Re-rendered (framework swapped the node). Only follow it when the label is unambiguous.
258
+ await _observe()
259
+ same = [k for k, v in S["index"].items() if v["label"] == entry["label"] and v["frame"] == entry["frame"]]
260
+ if len(same) != 1:
261
+ raise Stale(f"[{idx}] is gone from the page.")
262
+ idx, entry = same[0], S["index"][same[0]]
263
+ el = await _element(entry)
264
+ if el is None:
265
+ raise Stale(f"[{idx}] is gone from the page.")
266
+ S["notes"].append(f"The element was re-rendered; used [{idx}] {entry['label']!r}.")
267
+ option = entry["options"][int(opt) - 1][0] if opt else None
268
+ return el, option
269
+ return page.locator(target).first, None
270
+
271
+
272
+ async def _element(entry):
273
+ handle = await entry["frame"].evaluate_handle(
274
+ "id => { const e = window.__jevFast?.nodes.get(id); return e?.isConnected ? e : null; }", entry["node"]
275
+ )
276
+ return handle.as_element()
277
+
278
+
279
+ async def _run(coro):
280
+ page = await _page()
281
+ S["notes"] = []
282
+ before = await _fingerprint(page)
283
+ try:
284
+ await coro
285
+ except Stale as e:
286
+ return f"Stale: {e} Fresh table:\n\n" + await _observe()
287
+ except ValueError as e:
288
+ return f"Error: {e}\n\n" + await _observe()
289
+ except (PWError, PWTimeout) as e:
290
+ if before is None or await _fingerprint(page) == before:
291
+ return f"Failed: {str(e).splitlines()[0][:300]}\n\n" + await _observe()
292
+ S["notes"].append(
293
+ "The action timed out, but the page changed since. Check whether it took effect before retrying."
294
+ )
295
+ await _settle(page)
296
+ await _quiet(page)
297
+ table = await _observe()
298
+ return "".join(f"Note: {n}\n" for n in S["notes"]) + ("\n" if S["notes"] else "") + table
299
+
300
+
301
+ @mcp.tool()
302
+ @_one_at_a_time
303
+ async def navigate(url: str) -> str:
304
+ """Open url and return the indexed element table."""
305
+ page = await _page()
306
+ await page.goto(url, wait_until="domcontentloaded", timeout=30000)
307
+ await _settle(page)
308
+ await _quiet(page)
309
+ return await _observe()
310
+
311
+
312
+ @mcp.tool()
313
+ @_one_at_a_time
314
+ async def observe(full: bool = False) -> str:
315
+ """Indexed table of interactive elements (all frames) plus visible text.
316
+ Default: viewport only (compact). full=true: whole page."""
317
+ return await _observe(full)
318
+
319
+
320
+ @mcp.tool()
321
+ @_one_at_a_time
322
+ async def act(operation: str, target: str = "", text: str = "") -> str:
323
+ """Do one thing, then return the fresh table.
324
+ operation: CLICK | TYPE | SELECT | PRESS | HOVER | SCROLL_DOWN | SCROLL_UP | WAIT.
325
+ Returns once the page settles (max 2s). If it is still loading after that, WAIT (returns on change, max 3s).
326
+ target: index from the table ("3"; "3:2" picks a SELECT option), or a Playwright selector
327
+ ("input[type=password]", "text=Send", "role=button[name='OK']") for anything not in the table.
328
+ text: TYPE value, SELECT option label (instead of "3:2"), or PRESS key ("Enter")."""
329
+ op = operation.upper().replace("TYPE_TEXT", "TYPE")
330
+ page = await _page()
331
+
332
+ async def do():
333
+ if op in ("SCROLL_DOWN", "SCROLL_UP"):
334
+ await page.mouse.wheel(0, 600 if op == "SCROLL_DOWN" else -600)
335
+ return
336
+ if op == "WAIT":
337
+ await _wait_for_change(page)
338
+ return
339
+ if op == "PRESS" and not target:
340
+ await page.keyboard.press(text)
341
+ return
342
+ if not target:
343
+ raise ValueError(f"{op} needs a target.")
344
+ el, option = await _resolve(target)
345
+ if op == "CLICK":
346
+ await el.click(timeout=5000, no_wait_after=True)
347
+ elif op == "TYPE":
348
+ await el.fill(text, timeout=5000)
349
+ elif op == "SELECT" and option:
350
+ await el.select_option(value=option, timeout=5000)
351
+ elif op == "SELECT":
352
+ await el.select_option(label=text, timeout=5000)
353
+ elif op == "PRESS":
354
+ await el.press(text, timeout=5000, no_wait_after=True)
355
+ elif op == "HOVER":
356
+ await el.hover(timeout=5000)
357
+ else:
358
+ raise ValueError(f"Unknown operation {operation!r}.")
359
+
360
+ return await _run(do())
361
+
362
+
363
+ @mcp.tool()
364
+ @_one_at_a_time
365
+ async def upload(target: str, paths: list[str]) -> str:
366
+ """Set files on a file input. target: selector (file inputs are not in the table), e.g. "input[type=file]"."""
367
+
368
+ async def do():
369
+ el, _ = await _resolve(target)
370
+ await el.set_input_files(paths, timeout=5000)
371
+
372
+ return await _run(do())
373
+
374
+
375
+ @mcp.tool()
376
+ @_one_at_a_time
377
+ async def screenshot(full_page: bool = False) -> Image:
378
+ """JPEG of the current page."""
379
+ page = await _page()
380
+ return Image(data=await page.screenshot(type="jpeg", quality=70, full_page=full_page), format="jpeg")
381
+
382
+
383
+ @mcp.tool()
384
+ @_one_at_a_time
385
+ async def evaluate(js: str, max_chars: int = 6000) -> str:
386
+ """Run JS in the page: an expression, or a function like "() => document.title".
387
+ Returns JSON, cut at max_chars (0: no limit)."""
388
+ page = await _page()
389
+ out = json.dumps(await page.evaluate(js), ensure_ascii=False, default=str)
390
+ if max_chars and len(out) > max_chars:
391
+ return f"{out[:max_chars]}\n(cut at {max_chars} of {len(out)} chars; max_chars=0 returns all of it)"
392
+ return out
393
+
394
+
395
+ @mcp.tool()
396
+ @_one_at_a_time
397
+ async def console(clear: bool = True) -> str:
398
+ """Console messages and page errors since the last call."""
399
+ lines = S["console"][-100:]
400
+ if clear:
401
+ S["console"].clear()
402
+ return "\n".join(lines) or "(empty)"
403
+
404
+
405
+ @mcp.tool()
406
+ @_one_at_a_time
407
+ async def close() -> str:
408
+ """Close the browser. The next call starts a fresh, empty session."""
409
+ if S["browser"]:
410
+ await S["browser"].close()
411
+ await S["pw"].stop()
412
+ S.update(pw=None, browser=None, ctx=None, page=None, index={}, console=[], fp=None, notes=[], inflight={})
413
+ return "closed"
414
+
415
+
416
+ def main():
417
+ mcp.run()
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()
pointclick/snapshot.js ADDED
@@ -0,0 +1,112 @@
1
+ // Adapted from browser-use/jev-ultrafast (MIT, see LICENSE-jev). Changes:
2
+ // `full` lifts the viewport filter; a control repeating its wrapper's label is dropped;
3
+ // a <button> is named by its text, not its value.
4
+ (full => {
5
+ if (!document.body) return null;
6
+ const cache = window.__jevFast ||= {ids:new WeakMap(), nodes:new Map(), next:1};
7
+ const identity = e => {
8
+ if (!cache.ids.has(e)) cache.ids.set(e,cache.next++);
9
+ const id=cache.ids.get(e); cache.nodes.set(id,e); return id;
10
+ };
11
+ for (const [id,e] of cache.nodes) if (!e.isConnected) cache.nodes.delete(id);
12
+ const safe = e => !['password','file','hidden'].includes(e.type);
13
+ const visible = e => !e.closest('[aria-hidden="true"],[inert]') &&
14
+ e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});
15
+ const name = (e,seen=new Set()) => {
16
+ if (!e || seen.has(e)) return '';
17
+ seen.add(e);
18
+ const referenced=(e.getAttribute('aria-labelledby')||'').split(/\s+/)
19
+ .map(id=>name(document.getElementById(id),seen)).filter(Boolean).join(' ');
20
+ return referenced || e.getAttribute('aria-label') ||
21
+ [...(e.labels||[])].map(l=>name(l,seen)).filter(Boolean).join(' ') ||
22
+ (e.tagName==='INPUT' && ['button','submit','reset'].includes(e.type) ? e.value : '') || e.getAttribute('alt') ||
23
+ (e.tagName==='INPUT' ? '' : [...e.childNodes].map(n=>n.nodeType===3 ? n.textContent :
24
+ n.nodeType===1 && n.getAttribute('aria-hidden')!=='true' ? name(n,seen) : '').join(' ').trim()) ||
25
+ e.getAttribute('title') || e.getAttribute('placeholder') || '';
26
+ };
27
+ const roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio',
28
+ 'option','gridcell','combobox','textbox','searchbox','spinbutton'];
29
+ const selector='a[href],button,input,textarea,select,summary,[contenteditable="true"],'+
30
+ roles.map(role=>'[role="'+role+'"]').join(',');
31
+ const role = e => {
32
+ const explicit=e.getAttribute('role');
33
+ if (roles.includes(explicit)) return explicit;
34
+ if (e.tagName==='BUTTON' || e.tagName==='SUMMARY') return 'button';
35
+ if (e.tagName==='A') return 'link';
36
+ if (e.tagName==='SELECT') return 'combobox';
37
+ if (e.tagName==='TEXTAREA' || e.isContentEditable) return 'textbox';
38
+ if (e.tagName==='INPUT') {
39
+ if (['checkbox','radio'].includes(e.type)) return e.type;
40
+ if (['button','submit','reset','image'].includes(e.type)) return 'button';
41
+ if (e.type==='search') return 'searchbox';
42
+ if (e.type==='number') return 'spinbutton';
43
+ if (['text','email','url','tel'].includes(e.type)) return 'textbox';
44
+ }
45
+ return null;
46
+ };
47
+ cache.pageKey=()=>[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
48
+ [...document.querySelectorAll('input,textarea,select')].filter(safe)
49
+ .map(e=>[identity(e),e.value,e.checked,e.selectedIndex,e.disabled,e.readOnly])];
50
+ cache.guard=e=>{
51
+ if (!e?.isConnected || !visible(e)) return null;
52
+ const scope=e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
53
+ return [identity(e),role(e),name(e),e.value??null,e.checked??null,e.selectedIndex??null,
54
+ e.readOnly??null,e.matches(':disabled'),e.getAttribute('aria-disabled'),
55
+ e.getAttribute('aria-expanded'),e.getAttribute('aria-checked'),e.getAttribute('aria-selected'),
56
+ e.getAttribute('href'),scope?.innerText?.slice(0,6000)||''];
57
+ };
58
+ const actions=[];
59
+ for (const e of document.querySelectorAll(selector)) {
60
+ if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"]')) continue;
61
+ const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
62
+ if (!rname || r.width<=0 || r.height<=0 || (!full && (x<0 || y<0 || x>=innerWidth || y>=innerHeight))) continue;
63
+ if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
64
+ const outer=e.parentElement?.closest(selector);
65
+ if (outer && name(outer).replace(/\s+/g,' ').trim()===name(e).replace(/\s+/g,' ').trim()) continue;
66
+ const base={node:identity(e),role:rname,label:name(e)||rname,
67
+ rect:{x:r.x,y:r.y,w:r.width,h:r.height}};
68
+ for (const key of ['checked','selected','expanded']) {
69
+ const value=e.getAttribute('aria-'+key);
70
+ if (value!==null) base[key]=value;
71
+ }
72
+ if (['checkbox','radio'].includes(e.type)) base.checked=String(e.checked);
73
+ if (e.tagName==='SELECT') {
74
+ for (const o of e.options) if (!o.selected && !o.disabled && !o.closest('optgroup[disabled]'))
75
+ actions.push({...base,kind:'select',value:o.value,
76
+ current_value:[...e.selectedOptions].map(o=>o.label).join(', '),label:base.label+' → '+o.label});
77
+ } else {
78
+ const editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' &&
79
+ (['textbox','searchbox','spinbutton'].includes(rname) ||
80
+ (rname==='combobox' && ['INPUT','TEXTAREA'].includes(e.tagName)));
81
+ const value='value' in e ? String(e.value) :
82
+ e.isContentEditable || rname==='combobox' ? e.innerText.trim() : '';
83
+ actions.push({...base,kind:editable?'fill':'click',value});
84
+ if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
85
+ }
86
+ }
87
+ const words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
88
+ const range=document.createRange(); let node,length=0;
89
+ while ((node=walker.nextNode()) && length<6000) {
90
+ const value=node.textContent.trim(), parent=node.parentElement;
91
+ if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
92
+ range.selectNodeContents(node); const r=range.getBoundingClientRect();
93
+ if (r.width>0 && r.height>0 && (full || (r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth))) {
94
+ words.push(value); length+=value.length;
95
+ }
96
+ }
97
+ const text=words.join('\n').slice(0,6000), height=document.documentElement.scrollHeight;
98
+ const page_key=cache.pageKey(), guards={};
99
+ for (const a of actions) if (!(a.node in guards)) guards[a.node]=cache.guard(cache.nodes.get(a.node));
100
+ // Compare meaning and identity. Geometry is always resolved and hit-tested just before input.
101
+ const semantics=actions.map(({rect,...action})=>action);
102
+ const marker=[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
103
+ document.title,text,semantics,page_key[6]];
104
+ const omitted_actions=Math.max(0,actions.length-250);
105
+ actions.splice(250);
106
+ actions.forEach((a,i)=>a.id='e'+(i+1));
107
+ if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
108
+ if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
109
+ actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
110
+ return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
111
+ scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
112
+ })
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.5
2
+ Name: pointclick
3
+ Version: 0.1.0
4
+ Summary: A browser MCP server with a small footprint: a numbered list of what you can click, and eight tools.
5
+ Project-URL: Homepage, https://github.com/dashgin/pointclick
6
+ Project-URL: Issues, https://github.com/dashgin/pointclick/issues
7
+ Author-email: Dashgin <dashgin@praxis.az>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ License-File: LICENSE-jev
11
+ Keywords: agent,automation,browser,mcp,playwright
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: mcp>=2
19
+ Requires-Dist: playwright>=1.50
20
+ Description-Content-Type: text/markdown
21
+
22
+ # pointclick
23
+
24
+ **A browser MCP server in under 1,000 tokens.** The page comes back as a numbered list of what
25
+ you can click, type into or select. Your agent points at a number; pointclick clicks it.
26
+
27
+ [![PyPI](https://img.shields.io/pypi/v/pointclick.svg)](https://pypi.org/project/pointclick/)
28
+ [![CI](https://github.com/dashgin/pointclick/actions/workflows/ci.yml/badge.svg)](https://github.com/dashgin/pointclick/actions/workflows/ci.yml)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
30
+
31
+ ```
32
+ url: https://en.wikipedia.org/wiki/Main_Page
33
+ title: Wikipedia, the free encyclopedia
34
+ [1] link 'Wikipedia The Free Encyclopedia' <CLICK>
35
+ [2] searchbox 'Search Wikipedia' <TYPE,CLICK>
36
+ [3] button 'Search' <CLICK>
37
+ ...
38
+ (more below: 3156px; SCROLL_DOWN or observe(full=true))
39
+
40
+ text:
41
+ Welcome to
42
+ Wikipedia
43
+ ...
44
+ ```
45
+
46
+ ```
47
+ act("TYPE", "2", "Ada Lovelace") → the new list, once the page has settled
48
+ act("PRESS", "2", "Enter")
49
+ ```
50
+
51
+ ## Why
52
+
53
+ Same task, same model (Claude Sonnet 5 in headless Claude Code), one browser MCP each —
54
+ sign in, search, sort, open a record, read a number off it. 18 of 18 runs got it right.
55
+
56
+ | | turns | tokens read | cost per run | time |
57
+ |---|---|---|---|---|
58
+ | **pointclick** | **7** | **104k** | **$0.056** | **15.8 s** |
59
+ | Playwright MCP | 13 | 266k | $0.118 | 27.5 s |
60
+ | agent-browser | 12 | 525k | $0.223 | 17.8 s |
61
+
62
+ Medians. Cost is the API price Claude Code reports; time is noisy at this sample size.
63
+
64
+ - **Eight tools, under 1k tokens of definitions.** Playwright MCP's are ~5k, Chrome DevTools MCP's
65
+ ~7k, agent-browser's ~18k. Clients that load every tool up front pay that on every turn.
66
+ - **Only what you can act on.** No wrapper `div`s, no layout tree: controls, their state, and the
67
+ visible text.
68
+ - **Every action returns the settled page.** No follow-up snapshot call, and no waiting for the
69
+ network to go quiet: long-polls and streams don't stall it.
70
+
71
+ ## Quick start
72
+
73
+ Needs Python 3.12+ and [uv](https://docs.astral.sh/uv/). Uses your installed Chrome, or
74
+ Playwright's Chromium if there is none (`uvx --from pointclick playwright install chromium`).
75
+
76
+ **Claude Code**
77
+
78
+ ```bash
79
+ claude mcp add pointclick -- uvx pointclick
80
+ ```
81
+
82
+ **Cursor, Claude Desktop, Windsurf, and other clients** — add to the MCP config:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "pointclick": { "command": "uvx", "args": ["pointclick"] }
88
+ }
89
+ }
90
+ ```
91
+
92
+ **VS Code**
93
+
94
+ ```bash
95
+ code --add-mcp '{"name":"pointclick","command":"uvx","args":["pointclick"]}'
96
+ ```
97
+
98
+ **Codex**
99
+
100
+ ```bash
101
+ codex mcp add pointclick -- uvx pointclick
102
+ ```
103
+
104
+ ### Your first prompt
105
+
106
+ ```
107
+ Open news.ycombinator.com, go to the second page, and tell me the top story there.
108
+ ```
109
+
110
+ ## Tools
111
+
112
+ | Tool | |
113
+ |---|---|
114
+ | `navigate(url)` | Open a page, return the list |
115
+ | `observe(full=false)` | The list again. Viewport only unless `full` |
116
+ | `act(operation, target, text)` | `CLICK` `TYPE` `SELECT` `PRESS` `HOVER` `SCROLL_DOWN` `SCROLL_UP` `WAIT` |
117
+ | `upload(target, paths)` | Set files on a file input |
118
+ | `screenshot(full_page=false)` | JPEG |
119
+ | `evaluate(js, max_chars=6000)` | Run JS in the page, JSON back. A longer result is cut and says so; `0` for all of it |
120
+ | `console()` | Console messages and page errors since the last call |
121
+ | `close()` | End the session; the next call starts fresh |
122
+
123
+ `target` is a number from the list (`"3"`, or `"3:2"` for the second option of a select), or any
124
+ Playwright selector (`"input[type=password]"`, `"text=Send"`, `"role=button[name='OK']"`).
125
+ Password and file inputs are never in the list; reach them by selector.
126
+
127
+ | Env | |
128
+ |---|---|
129
+ | `HEADED=1` | Show the window |
130
+ | `BROWSER_CHANNEL` | `chrome` (default), `msedge`, or `chromium` for Playwright's bundled build |
131
+
132
+ ## How it works
133
+
134
+ - **The list** comes from one script that reads every visible control and the visible text in a
135
+ single pass — adapted from [jev-ultrafast](https://github.com/browser-use/jev-ultrafast)
136
+ (Browser Use × TypeSafe). It runs in every frame, cross-origin ones included, and a control that
137
+ only repeats its wrapper's label is listed once.
138
+ - **Settling.** After an action, pointclick returns once the page has been still for 150 ms and
139
+ no request younger than 0.5 s is pending — at most 2 s. Older requests (long-polls, streams)
140
+ don't hold it up. If the page is still busy after that, `WAIT` returns the moment it changes.
141
+ - **Numbers point at real elements**, not at a position. If a framework re-renders the element,
142
+ pointclick follows it when its label is unique on the page and says so; a duplicate label (two
143
+ "Start" buttons) is refused rather than guessed.
144
+ - **A timeout on a page that changed anyway** is reported that way, so the agent checks before
145
+ retrying instead of clicking twice.
146
+ - **Playwright underneath** handles selectors, uploads, popups (they become the current page),
147
+ screenshots and the console. Every session is a fresh, isolated browser context.
148
+
149
+ `server.py` is ~350 lines and `snapshot.js` ~110. Small enough to read before you trust it.
150
+
151
+ ## Benchmarks
152
+
153
+ `bench/` has a small SPA shaped like a real dashboard — demo login, a list with search and sort, a
154
+ detail page, API calls over the network, and an optional long-poll like a realtime fallback.
155
+
156
+ **With a real agent** — headless Claude Code, built-in tools off, the task given in plain words:
157
+
158
+ ```bash
159
+ uv run python bench/agent_race.py --runs 3 --model sonnet
160
+ ```
161
+
162
+ | | correct | turns | tokens read | tokens written | cost | time, no long-poll | time, long-poll open |
163
+ |---|---|---|---|---|---|---|---|
164
+ | **pointclick** | 6/6 | **7** | **104k** | **620–660** | **$0.056** | **13.7 s** | 16.7 s |
165
+ | Playwright MCP 0.0.81 | 6/6 | 13 | 266–289k | 1.1k | $0.113–0.118 | 23.3 s | 28.5 s |
166
+ | agent-browser 0.38.1 | 6/6 | 12 | 525k | 760–780 | $0.223 | 19.1 s | **14.8 s** |
167
+
168
+ Medians of 3 runs per column. Turns and tokens barely move between runs; time moves by up to a
169
+ third with model latency, so read it as a range. Playwright MCP's action replies link to a
170
+ snapshot file instead of including the page, so the model asks for a snapshot after each step.
171
+ agent-browser's actions return nothing, so every step is an action plus a snapshot too.
172
+
173
+ **Tools alone** — the same flow driven by a script, so no model time is included:
174
+
175
+ ```bash
176
+ uv run python bench/race.py --runs 8 --poll 0
177
+ ```
178
+
179
+ | | calls | text to the model | tool time | with a long-poll open |
180
+ |---|---|---|---|---|
181
+ | **pointclick** | **6** | **6.6k chars (~1.9k tokens)** | 2.25 s | **2.31 s** |
182
+ | Playwright MCP 0.0.81 | 6 | 17.0k chars (~4.9k tokens) | 3.32 s | 8.27 s |
183
+ | agent-browser 0.38.1 | 18 | 12.3k chars (~3.5k tokens) | **1.13 s** | 1.12 s |
184
+
185
+ agent-browser's tool time is lowest because its actions return nothing: it re-snapshots, and the
186
+ script polls for free. In a real agent every one of those calls is a model turn.
187
+
188
+ **Tool definitions**, sent with every request by clients that don't load tools lazily (all four
189
+ estimated the same way, JSON characters ÷ 3.5):
190
+
191
+ | | tools | definitions |
192
+ |---|---|---|
193
+ | **pointclick** | **8** | **~0.8k tokens** |
194
+ | Playwright MCP | 26 | ~5.2k tokens |
195
+ | Chrome DevTools MCP | 29 | ~6.9k tokens |
196
+ | agent-browser (`core`) | 29 | ~18.4k tokens |
197
+
198
+ Measured on an M-series Mac with Chrome 153. Raw results are in `bench/*.json`.
199
+
200
+ ## When to use something else
201
+
202
+ - **Playwright MCP** — you need network interception, tracing, PDF export, or the full Playwright
203
+ surface.
204
+ - **Chrome DevTools MCP** — performance traces, Lighthouse-style audits, deep DevTools debugging.
205
+ - **agent-browser** — you'd rather drive the browser from a shell (CLI + skills), want saved auth
206
+ profiles, or need its long tail of commands.
207
+ - **Stagehand** — you want natural-language `act`/`extract` backed by its own model, or hosted
208
+ browsers on Browserbase.
209
+
210
+ pointclick does one job: get an agent through a web app with as little text and as few turns as
211
+ it can — locally, with no account and no second model.
212
+
213
+ ## Security
214
+
215
+ - Everything on a page — text, labels, titles — is **untrusted input** to your model. A page can
216
+ try to instruct your agent. Keep the agent's permissions narrow and confirm consequential steps.
217
+ - Each session is an isolated browser context: no access to your Chrome profile, cookies or
218
+ passwords. Nothing persists after `close()`.
219
+ - `evaluate` runs arbitrary JavaScript in the page; `upload` reads files you name from disk.
220
+
221
+ ## Limits
222
+
223
+ - The list is viewport-only by default; long pages take `SCROLL_DOWN` or `observe(full=true)`.
224
+ - Every action returns the whole list, not a diff.
225
+ - Chromium only, one page at a time per server. Calls sent in parallel run one after another.
226
+ - No network inspection, PDF export or dialog control; `evaluate` covers some of it.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ uv sync --group dev
232
+ uv run pytest # 45 tests; fixture pages served from two local origins
233
+ uv run ruff check . && uv run ruff format --check .
234
+ ```
235
+
236
+ To release, bump `version` in `pyproject.toml`, then push a matching tag (`v0.1.0`). `release.yml` publishes
237
+ to PyPI through trusted publishing; there is no token to keep.
238
+
239
+ ## Credits
240
+
241
+ The page snapshot is adapted from [jev-ultrafast](https://github.com/browser-use/jev-ultrafast),
242
+ MIT © 2026 Browser Use — see `LICENSE-jev`.
243
+
244
+ ## License
245
+
246
+ MIT
@@ -0,0 +1,9 @@
1
+ pointclick/__init__.py,sha256=hyKRHYhJ1P99GUL5bd4Efz7Pi6RsbIh4B0wJNO5eI9k,81
2
+ pointclick/server.py,sha256=u5OxOto8RaLUrEg7IWJiEb3NFvQkootabMuoO-vkEZ4,14722
3
+ pointclick/snapshot.js,sha256=UESumNjTyLgiZLZa_-zX_EuHFPOHKiLygoAxiHEqFFk,6909
4
+ pointclick-0.1.0.dist-info/METADATA,sha256=jgcJG4EOEF3s12dLu4vfsvNRfdeLLnj_rGRVbZ5jxng,10032
5
+ pointclick-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ pointclick-0.1.0.dist-info/entry_points.txt,sha256=XrS2c2Q40Pon8wHbfpaf0T72SEm5-QD8F0JrUtqsFQo,54
7
+ pointclick-0.1.0.dist-info/licenses/LICENSE,sha256=H2xzjbUsz8hg3tHkGMEuI78ezi6Bl0sjMV4XqR-ujJw,1064
8
+ pointclick-0.1.0.dist-info/licenses/LICENSE-jev,sha256=Wvo9l78eb5mNWH-hAeP-DQxBaEC3_ne81q6XaQozRjE,1068
9
+ pointclick-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pointclick = pointclick.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dashgin
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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Browser Use
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.