feat(mcp): target DOM tools by tab
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m4s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / test (push) Successful in 1m21s

- Add server-side search filtering to the MCP tabs_list tool.
- Let MCP DOM query, click, and type tools operate on an explicit tab_id.
- Use tab-scoped DOM eval fallbacks when the SDK active-tab helpers are not enough.
- Cover tab filtering and tab-targeted DOM operations in MCP tests.
- Bump package and extension versions to 0.16.8 and refresh the lockfile.
This commit is contained in:
2026-10-09 08:58:46 +02:00
parent 34aaf78c66
commit 6c0f77e416
5 changed files with 790 additions and 588 deletions
+63 -8
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import argparse import argparse
import base64 import base64
import json
import os import os
from collections.abc import Callable from collections.abc import Callable
from typing import Any, Literal from typing import Any, Literal
@@ -68,12 +69,21 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
@mcp.tool(name=tool_name("tabs_list", prefix)) @mcp.tool(name=tool_name("tabs_list", prefix))
def tabs_list( def tabs_list(
search: str | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List current tabs. Optionally target a browser alias or authenticated remote.""" """List current tabs, optionally filtering by URL or title substring."""
return structured(_client(client_factory, browser, remote, key).tabs.list()) tabs = structured(_client(client_factory, browser, remote, key).tabs.list())
if not search:
return tabs
needle = search.lower()
return [
tab for tab in tabs
if needle in str(tab.get("url") or "").lower()
or needle in str(tab.get("title") or "").lower()
]
@mcp.tool(name=tool_name("tabs_open", prefix)) @mcp.tool(name=tool_name("tabs_open", prefix))
def tabs_open( def tabs_open(
@@ -155,35 +165,80 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
@mcp.tool(name=tool_name("dom_query", prefix)) @mcp.tool(name=tool_name("dom_query", prefix))
def dom_query( def dom_query(
selector: str, selector: str,
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return elements matching a CSS selector on the active page.""" """Return elements matching a CSS selector on the active page or tab_id."""
return structured(_client(client_factory, browser, remote, key).dom.query(selector)) client = _client(client_factory, browser, remote, key)
if tab_id is None:
return structured(client.dom.query(selector))
code = f"""
(() => {{
const selector = {json.dumps(selector)};
return [...document.querySelectorAll(selector)].map(el => ({{
tag: el.tagName,
text: (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(),
attrs: Object.fromEntries([...el.attributes].map(attr => [attr.name, attr.value])),
}}));
}})()
"""
return structured(client.dom.eval(code, tab_id))
@mcp.tool(name=tool_name("dom_click", prefix)) @mcp.tool(name=tool_name("dom_click", prefix))
def dom_click( def dom_click(
selector: str, selector: str,
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Click the first matching element, then return current active-page info.""" """Click the first matching element, then return current page info."""
client = _client(client_factory, browser, remote, key) client = _client(client_factory, browser, remote, key)
client.dom.click(selector) if tab_id is None:
return structured(client.page.info()) client.dom.click(selector)
return structured(client.page.info())
code = f"""
(() => {{
const selector = {json.dumps(selector)};
const el = document.querySelector(selector);
if (!el) throw new Error(`No element found for selector: ${{selector}}`);
el.click();
return true;
}})()
"""
client.dom.eval(code, tab_id)
return structured(client.page.info(tab_id))
@mcp.tool(name=tool_name("dom_type", prefix)) @mcp.tool(name=tool_name("dom_type", prefix))
def dom_type( def dom_type(
selector: str, selector: str,
text: str, text: str,
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> dict[str, bool]: ) -> dict[str, bool]:
"""Type text into the first element matching a CSS selector.""" """Type text into the first element matching a CSS selector."""
_client(client_factory, browser, remote, key).dom.type(selector, text) client = _client(client_factory, browser, remote, key)
if tab_id is None:
client.dom.type(selector, text)
return {"typed": True}
code = f"""
(() => {{
const selector = {json.dumps(selector)};
const text = {json.dumps(text)};
const el = document.querySelector(selector);
if (!el) throw new Error(`No element found for selector: ${{selector}}`);
el.focus();
el.value = text;
el.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertText', data: text }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return true;
}})()
"""
client.dom.eval(code, tab_id)
return {"typed": True} return {"typed": True}
@mcp.tool(name=tool_name("screenshot", prefix), structured_output=False) @mcp.tool(name=tool_name("screenshot", prefix), structured_output=False)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.16.7", "version": "0.16.8",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.7" version = "0.16.8"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+46 -3
View File
@@ -33,11 +33,14 @@ class FakeClient:
self.nav = SimpleNamespace(to=self.navigate_to) self.nav = SimpleNamespace(to=self.navigate_to)
self.page = SimpleNamespace(info=self.page_info) self.page = SimpleNamespace(info=self.page_info)
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown) self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type) self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type, eval=self.dom_eval)
self.instances.append(self) self.instances.append(self)
def tabs_list(self): def tabs_list(self):
return [{"id": 7, "title": "Example", "url": "https://example.com"}] return [
{"id": 7, "title": "Example", "url": "https://example.com"},
{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"},
]
def tabs_open(self, url, **kwargs): def tabs_open(self, url, **kwargs):
self.calls.append(("open", url, kwargs)) self.calls.append(("open", url, kwargs))
@@ -83,6 +86,12 @@ class FakeClient:
def dom_type(self, selector, text): def dom_type(self, selector, text):
self.calls.append(("type", selector, text)) self.calls.append(("type", selector, text))
def dom_eval(self, code, tab_id=None):
self.calls.append(("eval", code, tab_id))
if "querySelectorAll" in code:
return [{"tag": "BUTTON", "text": "Buchen", "attrs": {"id": "buchen"}}]
return True
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_instances(): def clear_instances():
FakeClient.instances.clear() FakeClient.instances.clear()
@@ -110,7 +119,10 @@ async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeyp
second = await client.call_tool("browser_page_info", {}) second = await client.call_tool("browser_page_info", {})
assert first.structured_content == { assert first.structured_content == {
"result": [{"id": 7, "title": "Example", "url": "https://example.com"}] "result": [
{"id": 7, "title": "Example", "url": "https://example.com"},
{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"},
]
} }
assert second.structured_content == { assert second.structured_content == {
"title": "Example", "title": "Example",
@@ -158,6 +170,37 @@ async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
assert FakeClient.instances[-1].target == ("main", None, None) assert FakeClient.instances[-1].target == ("main", None, None)
@pytest.mark.anyio
async def test_tabs_list_can_filter_large_tab_sets_server_side(client, monkeypatch):
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
result = await client.call_tool("browser_tabs_list", {"search": "browser-host.example", "browser": "main"})
assert result.structured_content == {
"result": [{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"}]
}
assert FakeClient.instances[-1].target == ("main", None, None)
@pytest.mark.anyio
async def test_dom_tools_accept_explicit_tab_id(client):
queried = await client.call_tool("browser_dom_query", {"selector": "button", "tab_id": 9})
clicked = await client.call_tool("browser_dom_click", {"selector": "#buchen", "tab_id": 9})
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel", "tab_id": 9})
assert queried.structured_content == {
"result": [{"tag": "BUTTON", "text": "Buchen", "attrs": {"id": "buchen"}}]
}
assert clicked.structured_content == {
"title": "Example",
"url": "https://example.com",
"tab_id": 9,
}
assert typed.structured_content == {"typed": True}
assert FakeClient.instances[0].calls == [("eval", FakeClient.instances[0].calls[0][1], 9)]
assert FakeClient.instances[1].calls[0][0] == "eval"
assert FakeClient.instances[1].calls[1] == ("page_info", 9)
assert FakeClient.instances[2].calls[0][0] == "eval"
@pytest.mark.anyio @pytest.mark.anyio
async def test_mutating_tools_use_sdk_and_return_fresh_state(client): async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
opened = await client.call_tool("browser_tabs_open", { opened = await client.call_tool("browser_tabs_open", {
Generated
+679 -575
View File
File diff suppressed because it is too large Load Diff