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.
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user