feat(mcp): target DOM tools by tab
- 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:
@@ -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,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
@@ -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
@@ -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", {
|
||||||
|
|||||||
Reference in New Issue
Block a user