From 581cd73cac9863696d0d7ac90493f2582a8d36ff Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sun, 9 Aug 2026 20:41:41 +0200 Subject: [PATCH] Default MCP tab tools to the active tab Requiring an explicit tab_id forced every navigate or close through a preceding tabs_list call, which costs an MCP client a full round trip just to learn the ID the browser already considers current. navigate and tabs_close now resolve the active tab when tab_id is omitted, matching the screenshot tool. Resolution is explicit rather than forwarding None into the SDK, so the acting tool knows which tab it touched; tabs_close reports it, since closing the wrong tab is not recoverable. This stays in the MCP layer: the SDK and CLI signatures are unchanged. --- README.md | 5 +++++ browser_cli/mcp/server.py | 19 +++++++++++-------- browser_cli/mcp/targets.py | 16 ++++++++++++++++ tests/test_mcp.py | 17 +++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 browser_cli/mcp/targets.py diff --git a/README.md b/README.md index 77a1bd2..0d7b804 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,11 @@ The server is stateless at the MCP layer. Every tool call creates a fresh `browser`, `remote`, and `key` on a tool call when a specific local profile or authenticated browser-cli remote is required. +`browser_navigate`, `browser_tabs_close`, and `browser_screenshot` take an +optional `tab_id` and act on the active tab when it is omitted, so a caller +does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close` +reports the tab it closed. + Available tools: - `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close` - `browser_navigate`, `browser_page_info` diff --git a/browser_cli/mcp/server.py b/browser_cli/mcp/server.py index 64f6f72..48ba0df 100644 --- a/browser_cli/mcp/server.py +++ b/browser_cli/mcp/server.py @@ -16,6 +16,7 @@ from urllib.parse import urlsplit from browser_cli import BrowserCLI from browser_cli.mcp.naming import resolve_tool_prefix, tool_name from browser_cli.mcp.serialization import structured +from browser_cli.mcp.targets import resolve_tab_id ClientFactory = Callable[..., BrowserCLI] @@ -93,27 +94,29 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st @mcp.tool(name=tool_name("tabs_close", prefix)) def tabs_close( - tab_id: int, + tab_id: int | None = None, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, int]: - """Close one tab by its current ID. This changes the user's real browser.""" - closed = _client(client_factory, browser, remote, key).tabs.close(tab_id) - return {"closed": closed} + """Close a tab, defaulting to the active tab. This changes the real browser.""" + client = _client(client_factory, browser, remote, key) + target = resolve_tab_id(client, tab_id) + return {"closed": client.tabs.close(target), "tab_id": target} @mcp.tool(name=tool_name("navigate", prefix)) def navigate( - tab_id: int, url: str, + tab_id: int | None = None, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, Any]: - """Navigate an existing tab to a URL and return a fresh tab snapshot.""" + """Navigate a tab to a URL, defaulting to the active tab, and return it.""" client = _client(client_factory, browser, remote, key) - client.nav.to(tab_id, url) - return structured(client.tabs.status(tab_id)) + target = resolve_tab_id(client, tab_id) + client.nav.to(target, url) + return structured(client.tabs.status(target)) @mcp.tool(name=tool_name("page_info", prefix)) def page_info( diff --git a/browser_cli/mcp/targets.py b/browser_cli/mcp/targets.py new file mode 100644 index 0000000..bbbc5e8 --- /dev/null +++ b/browser_cli/mcp/targets.py @@ -0,0 +1,16 @@ +"""Tab targeting for the MCP surface. + +MCP callers pay a full round trip for every extra tool call, so tools that act +on a tab accept an optional ``tab_id`` and fall back to the browser's current +active tab. Resolution happens here rather than by forwarding ``None`` into the +SDK, so the acting tool always knows which tab it touched and can report it. +""" +from __future__ import annotations + +from browser_cli import BrowserCLI + +def resolve_tab_id(client: BrowserCLI, tab_id: int | None) -> int: + """Return *tab_id*, or the ID of the currently active tab when it is ``None``.""" + if tab_id is not None: + return tab_id + return client.tabs.active().id diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 6185da8..5e0fea0 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -26,6 +26,7 @@ class FakeClient: list=self.tabs_list, open=self.tabs_open, close=self.tabs_close, + active=self.tabs_active, status=self.tabs_status, screenshot=self.tabs_screenshot, ) @@ -46,6 +47,10 @@ class FakeClient: self.calls.append(("close", tab_id)) return 1 + def tabs_active(self): + self.calls.append(("active",)) + return SimpleNamespace(id=7) + def tabs_status(self, tab_id): self.calls.append(("status", tab_id)) return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"} @@ -178,6 +183,18 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client): ("navigate", 8, "https://example.com/next"), ("status", 8) ] +@pytest.mark.anyio +async def test_tab_tools_default_to_the_active_tab(client): + navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"}) + closed = await client.call_tool("browser_tabs_close", {}) + + assert navigated.structured_content["id"] == 7 + assert closed.structured_content == {"closed": 1, "tab_id": 7} + assert FakeClient.instances[0].calls == [ + ("active",), ("navigate", 7, "https://example.com/next"), ("status", 7) + ] + assert FakeClient.instances[1].calls == [("active",), ("close", 7)] + @pytest.mark.anyio async def test_screenshot_returns_image_content(client): result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})