async def test_server_registers_expected_tools():
from invisible_playwright_mcp.mcp import server
tools = await server.mcp.list_tools()
names = {t.name for t in tools}
expected = {
"browser_navigate", "browser_read_text",
"browser_snapshot", "browser_read_html", "browser_click",
"browser_click_at", "browser_type", "browser_press_key",
"browser_evaluate", "browser_take_screenshot",
# Added in 0.10.0. Its absence was not neutral: with no way to set a
# dropdown, a model clicked it, pressed arrow keys blind, and ended up
# injecting script to set the value - which changes the page without
# it ever seeing a real interaction.
"browser_select_option",
# Added in 0.15.0: the window as a person sees it, pointer included,
# from the engine's screencast. It exists because every image the
# server returned was the content viewport, and the pointer is drawn
# outside the page on purpose - so nobody watching could see it.
"browser_watch",
# Added in 0.15.0 with the rest of the multi-browser work: a session
# held several browsers, so it needed a way to open one, close it, ask
# what it holds, and say which one the unaddressed commands mean.
"browser_open", "browser_close", "browser_list",
# ⛔ RENAMED ON 2026-09-11, WHEN MCP STOPPED HAVING A SESSION CONCEPT AT
# ALL: this process serves exactly one piece of work, told which by
# `INVISIBLE_MCP_SESSION_ID` at spawn time, never by a tool argument - so the
# tools that used to say "session" now say "browser", because there is
# no second one here to distinguish it from.
#
# `session_status` -> `browser_status`. `session_start` is GONE, folded
# entirely into `browser_open`, which already had to open the FIRST
# browser and only ever differed from a restart by name.
#
# ⛔ AND THE FOUR TAB TOOLS ARE GONE ENTIRELY (2026-09-11). They were
# briefly renamed `browser_tab_*` to match Microsoft's Playwright MCP,
# and then removed: a browser drives ONE page here, and the answer to
# "I need a second page" is the `support` browser, which is a better
# answer than a tab because a tab carries the identity's cookies and
# fingerprint to the second site. Nothing replaced them - `browser_
# navigate` opens the first page by itself.
#
# `session_list` and `session_forget` are GONE with no replacement:
# enumerating or deleting another piece of work is precisely the
# capability MCP no longer has. Listing THIS process's own two
# browsers is still `browser_list`; deleting a whole piece of work is
# done by whoever spawned this process closing it, not by a tool call.
"browser_status",
}
# EXACT, not a subset. `expected <= names` passed while a tool nobody
# meant to publish sat in the list, and the surface of an MCP server is
# exactly the thing a caller writes prompts against: it moves deliberately
# or not at all.
assert names == expected, {"missing": expected - names, "unexpected": names - expected}
async def test_every_tool_description_is_english_and_ascii():
"""A tool description is not documentation, it is the prompt the model reads
to decide whether to call the tool at all.
The repository-wide language gate cannot protect this: it looks for PROSE,
two Italian function words in the same file, and a one-line description is
not prose. That limitation is real and documented, so the surface that
matters most gets its own check rather than a longer word list, which would
be chasing cases one at a time.
"""
import re
from invisible_core.english import ITALIAN as italian
from invisible_playwright_mcp.mcp import server
# The word list is IMPORTED from the gate rather than copied. Copying it
# here had two costs at once: the list would drift from the one that
# actually guards the repository, and this file, being a page of Italian
# words, was itself flagged as Italian prose by that very gate. The gate
# itself lives in invisible_core since 30.23.0 - it used to be a script
# copied into this repository and into the wrapper, and the two copies
# drifted apart, four of this one's five exclusions naming paths that only
# exist in the other.
problems = {}
for tool in await server.mcp.list_tools():
text = tool.description or ""
found = [w for w in italian if re.search(rf"\b{w}\b", text, re.I)]
non_ascii = sorted({c for c in text if ord(c) > 127})
if found or non_ascii:
problems[tool.name] = {"italian": found, "non_ascii": non_ascii}
assert not problems, problems
async def test_every_tool_actually_has_a_description():
"""An undescribed tool is one the model will not choose, or will choose
wrongly. Cheaper to assert than to debug from the other side."""
from invisible_playwright_mcp.mcp import server
thin = {t.name: t.description for t in await server.mcp.list_tools()
if not (t.description or "").strip()}
assert not thin, thin