diff --git a/README.md b/README.md index 4375be15..3bcf28ac 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,40 @@ Profile names must match `^[A-Za-z0-9._-]+$`. `base_url` and `HYPERBROWSER_BASE_URL` accept either `https://host` or `https://host/api`. The client normalizes both to the same control-plane base URL. +## WebMCP page tools + +Create a session with `{"enable_web_mcp": True}` (default: false), then navigate +to a page that exposes WebMCP tools using Playwright. With that session open: + +```python +discovery = client.sessions.webmcp.list_tools(session.id) +# Select a tool using its name, source page/frame, and input schema. +tool = next(tool for tool in discovery.tools if tool.name == "search") +result = client.sessions.webmcp.invoke(session.id, { + "tool_ref": tool.tool_ref, + "input": {"query": "flights to Tokyo"}, # Match the selected tool's schema. +}) +print(result.status, result.output) +``` + +For long-running tools or forms awaiting human submission, use +`client.sessions.webmcp.start(session_id, params)`, then +`client.sessions.webmcp.get_result(session_id, invocation_id, {"wait_seconds": 30})` +until the status is terminal. `client.sessions.webmcp.cancel(session_id, invocation_id)` +requests best-effort cancellation. Blocking invocation defaults to 60 seconds +(maximum 120); `start` defaults to 300 seconds (maximum 3,600) via `timeout_seconds`. +All five methods also work with `AsyncHyperbrowser` by adding `await`. + +Discover tools again after navigation. Invocations never automatically retry; +losing a response can leave an unknown outcome. Handles are ephemeral and +terminal results expire after 10 minutes or earlier eviction. Check result +status and truncation metadata before using output. Tool input and output keys +are not renamed. Request TypedDicts are available in `hyperbrowser.types`, and +Pydantic request/response models in `hyperbrowser.models`. + +See the [WebMCP guide](https://hyperbrowser.ai/docs/sessions/webmcp) for complete +examples, cancellation behavior, human submission, and limits. + ## Usage Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method diff --git a/hyperbrowser/client/managers/async_manager/session.py b/hyperbrowser/client/managers/async_manager/session.py index 81dd5dca..79452284 100644 --- a/hyperbrowser/client/managers/async_manager/session.py +++ b/hyperbrowser/client/managers/async_manager/session.py @@ -1,4 +1,6 @@ import warnings + +from .webmcp import WebMCPManager from collections.abc import Mapping from typing import IO, List, Optional, Union, overload @@ -68,6 +70,7 @@ class SessionManager: def __init__(self, client): self._client = client self.event_logs = SessionEventLogsManager(client) + self.webmcp = WebMCPManager(client) async def create( self, diff --git a/hyperbrowser/client/managers/async_manager/webmcp.py b/hyperbrowser/client/managers/async_manager/webmcp.py new file mode 100644 index 00000000..c0ed44c2 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/webmcp.py @@ -0,0 +1,98 @@ +from typing import Optional, Union +from urllib.parse import quote + +from hyperbrowser.client._request import coerce_request, dump_request +from hyperbrowser.models.webmcp import ( + WebMCPInvocation, + WebMCPInvokeParams, + WebMCPInvokeResult, + WebMCPResultParams, + WebMCPStartParams, + WebMCPToolsResponse, +) +from hyperbrowser.types.webmcp import ( + WebMCPInvokeParams as WebMCPInvokeParamsDict, + WebMCPResultParams as WebMCPResultParamsDict, + WebMCPStartParams as WebMCPStartParamsDict, +) + + +class WebMCPManager: + """Discover and invoke page tools in sessions created with enable_web_mcp=True.""" + + def __init__(self, client): + self._client = client + + def _url(self, session_id: str, suffix: str) -> str: + return self._client._build_url( + f"/session/{quote(session_id, safe='')}/webmcp{suffix}" + ) + + async def list_tools(self, session_id: str) -> WebMCPToolsResponse: + """Discover document-bound tools across the session's tabs and frames.""" + response = await self._client.transport.get( + self._url(session_id, "/tools"), timeout=max(self._client.timeout, 40) + ) + return WebMCPToolsResponse(**response.data) + + async def invoke( + self, + session_id: str, + params: Union[WebMCPInvokeParamsDict, WebMCPInvokeParams], + ) -> WebMCPInvokeResult: + """Invoke once and wait. A form can return awaiting_submission with a handle. + + This POST is never automatically retried: losing its response can leave the + outcome unknown, and repeating it could duplicate a side effect. + """ + request = coerce_request(params, WebMCPInvokeParams) + response = await self._client.transport.post( + self._url(session_id, "/invoke"), + data=dump_request(request, WebMCPInvokeParams), + timeout=max(self._client.timeout, request.timeout_seconds + 35), + ) + return WebMCPInvokeResult(**response.data) + + async def start( + self, + session_id: str, + params: Union[WebMCPStartParamsDict, WebMCPStartParams], + ) -> WebMCPInvocation: + """Start once and return a handle; use get_result to observe its outcome.""" + response = await self._client.transport.post( + self._url(session_id, "/invocations"), + data=dump_request(params, WebMCPStartParams), + timeout=max(self._client.timeout, 40), + ) + return WebMCPInvocation(**response.data) + + async def get_result( + self, + session_id: str, + invocation_id: str, + params: Optional[Union[WebMCPResultParamsDict, WebMCPResultParams]] = None, + ) -> WebMCPInvocation: + """Read or long-poll once. Running/awaiting_submission can remain pending. + + Handles are in memory. Terminal results expire after 10 minutes and may + be evicted sooner; browser shutdown or receiver restart also loses them. + """ + request = coerce_request( + params if params is not None else {}, WebMCPResultParams + ) + response = await self._client.transport.get( + self._url(session_id, f"/invocations/{quote(invocation_id, safe='')}"), + params=dump_request(request, WebMCPResultParams), + timeout=max(self._client.timeout, request.wait_seconds + 20), + ) + return WebMCPInvocation(**response.data) + + async def cancel(self, session_id: str, invocation_id: str) -> WebMCPInvocation: + """Request best-effort cancellation. Completion can win; side effects persist.""" + response = await self._client.transport.post( + self._url( + session_id, f"/invocations/{quote(invocation_id, safe='')}/cancel" + ), + timeout=max(self._client.timeout, 20), + ) + return WebMCPInvocation(**response.data) diff --git a/hyperbrowser/client/managers/sync_manager/session.py b/hyperbrowser/client/managers/sync_manager/session.py index ec510bfe..97713c0d 100644 --- a/hyperbrowser/client/managers/sync_manager/session.py +++ b/hyperbrowser/client/managers/sync_manager/session.py @@ -1,4 +1,6 @@ import warnings + +from .webmcp import WebMCPManager from collections.abc import Mapping from typing import IO, List, Optional, Union, overload @@ -68,6 +70,7 @@ class SessionManager: def __init__(self, client): self._client = client self.event_logs = SessionEventLogsManager(client) + self.webmcp = WebMCPManager(client) def create( self, diff --git a/hyperbrowser/client/managers/sync_manager/webmcp.py b/hyperbrowser/client/managers/sync_manager/webmcp.py new file mode 100644 index 00000000..3c2a9a1d --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/webmcp.py @@ -0,0 +1,98 @@ +from typing import Optional, Union +from urllib.parse import quote + +from hyperbrowser.client._request import coerce_request, dump_request +from hyperbrowser.models.webmcp import ( + WebMCPInvocation, + WebMCPInvokeParams, + WebMCPInvokeResult, + WebMCPResultParams, + WebMCPStartParams, + WebMCPToolsResponse, +) +from hyperbrowser.types.webmcp import ( + WebMCPInvokeParams as WebMCPInvokeParamsDict, + WebMCPResultParams as WebMCPResultParamsDict, + WebMCPStartParams as WebMCPStartParamsDict, +) + + +class WebMCPManager: + """Discover and invoke page tools in sessions created with enable_web_mcp=True.""" + + def __init__(self, client): + self._client = client + + def _url(self, session_id: str, suffix: str) -> str: + return self._client._build_url( + f"/session/{quote(session_id, safe='')}/webmcp{suffix}" + ) + + def list_tools(self, session_id: str) -> WebMCPToolsResponse: + """Discover document-bound tools across the session's tabs and frames.""" + response = self._client.transport.get( + self._url(session_id, "/tools"), timeout=max(self._client.timeout, 40) + ) + return WebMCPToolsResponse(**response.data) + + def invoke( + self, + session_id: str, + params: Union[WebMCPInvokeParamsDict, WebMCPInvokeParams], + ) -> WebMCPInvokeResult: + """Invoke once and wait. A form can return awaiting_submission with a handle. + + This POST is never automatically retried: losing its response can leave the + outcome unknown, and repeating it could duplicate a side effect. + """ + request = coerce_request(params, WebMCPInvokeParams) + response = self._client.transport.post( + self._url(session_id, "/invoke"), + data=dump_request(request, WebMCPInvokeParams), + timeout=max(self._client.timeout, request.timeout_seconds + 35), + ) + return WebMCPInvokeResult(**response.data) + + def start( + self, + session_id: str, + params: Union[WebMCPStartParamsDict, WebMCPStartParams], + ) -> WebMCPInvocation: + """Start once and return a handle; use get_result to observe its outcome.""" + response = self._client.transport.post( + self._url(session_id, "/invocations"), + data=dump_request(params, WebMCPStartParams), + timeout=max(self._client.timeout, 40), + ) + return WebMCPInvocation(**response.data) + + def get_result( + self, + session_id: str, + invocation_id: str, + params: Optional[Union[WebMCPResultParamsDict, WebMCPResultParams]] = None, + ) -> WebMCPInvocation: + """Read or long-poll once. Running/awaiting_submission can remain pending. + + Handles are in memory. Terminal results expire after 10 minutes and may + be evicted sooner; browser shutdown or receiver restart also loses them. + """ + request = coerce_request( + params if params is not None else {}, WebMCPResultParams + ) + response = self._client.transport.get( + self._url(session_id, f"/invocations/{quote(invocation_id, safe='')}"), + params=dump_request(request, WebMCPResultParams), + timeout=max(self._client.timeout, request.wait_seconds + 20), + ) + return WebMCPInvocation(**response.data) + + def cancel(self, session_id: str, invocation_id: str) -> WebMCPInvocation: + """Request best-effort cancellation. Completion can win; side effects persist.""" + response = self._client.transport.post( + self._url( + session_id, f"/invocations/{quote(invocation_id, safe='')}/cancel" + ), + timeout=max(self._client.timeout, 20), + ) + return WebMCPInvocation(**response.data) diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 0ef56d88..54866e6a 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -1,3 +1,18 @@ +from .webmcp import ( + WebMCPAnnotations, + WebMCPFrame, + WebMCPSource, + WebMCPTool, + WebMCPToolsResponse, + WebMCPInvokeParams, + WebMCPStartParams, + WebMCPResultParams, + WebMCPInvokeStatus, + WebMCPInvocationStatus, + WebMCPInvokeResult, + WebMCPInvocationError, + WebMCPInvocation, +) from .web.batch_fetch import ( StartBatchFetchJobParams, StartBatchFetchJobResponse, @@ -439,6 +454,20 @@ from .team import TeamCreditInfo __all__ = [ + "WebMCPAnnotations", + "WebMCPFrame", + "WebMCPSource", + "WebMCPTool", + "WebMCPToolsResponse", + "WebMCPInvokeParams", + "WebMCPStartParams", + "WebMCPResultParams", + "WebMCPInvokeStatus", + "WebMCPInvocationStatus", + "WebMCPInvokeResult", + "WebMCPInvocationError", + "WebMCPInvocation", + # consts "ISO639_1", "POLLING_ATTEMPTS", diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index 4b8c9373..a40249c0 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -129,6 +129,8 @@ class SessionLaunchState(BaseModel): populate_by_alias=True, ) + enable_web_mcp: Optional[bool] = Field(default=None, alias="enableWebMcp") + use_ultra_stealth: Optional[bool] = Field(default=None, alias="useUltraStealth") use_stealth: Optional[bool] = Field(default=None, alias="useStealth") use_proxy: Optional[bool] = Field(default=None, alias="useProxy") @@ -411,6 +413,10 @@ class CreateSessionParams(BaseModel): populate_by_alias=True, ) + enable_web_mcp: Optional[bool] = Field( + default=None, serialization_alias="enableWebMcp" + ) + use_ultra_stealth: bool = Field( default=False, serialization_alias="useUltraStealth" ) diff --git a/hyperbrowser/models/webmcp.py b/hyperbrowser/models/webmcp.py new file mode 100644 index 00000000..60a326b3 --- /dev/null +++ b/hyperbrowser/models/webmcp.py @@ -0,0 +1,126 @@ +"""WebMCP request models and page-tool discovery/invocation responses.""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class WebMCPInvokeParams(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + tool_ref: str = Field(min_length=1, max_length=4096, serialization_alias="toolRef") + input: Dict[str, Any] = Field(default_factory=dict) + timeout_seconds: int = Field( + default=60, ge=1, le=120, strict=True, serialization_alias="timeoutSeconds" + ) + + +class WebMCPStartParams(WebMCPInvokeParams): + timeout_seconds: int = Field( + default=300, ge=1, le=3600, strict=True, serialization_alias="timeoutSeconds" + ) + + +class WebMCPResultParams(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + wait_seconds: int = Field( + default=0, ge=0, le=30, strict=True, serialization_alias="waitSeconds" + ) + + +class WebMCPAnnotations(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + read_only: bool = Field(alias="readOnly") + untrusted_content: bool = Field(alias="untrustedContent") + consequential: bool + autosubmit: bool + destructive: Optional[bool] = None + idempotent: Optional[bool] = None + open_world: Optional[bool] = Field(default=None, alias="openWorld") + + +class WebMCPFrame(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + url: str + is_main_frame: bool = Field(alias="isMainFrame") + + +class WebMCPSource(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + provider: Literal["native", "polyfill"] + tab_id: str = Field(alias="tabId") + page_url: str = Field(alias="pageUrl") + page_title: str = Field(alias="pageTitle") + frame: WebMCPFrame + + +class WebMCPTool(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + tool_ref: str = Field(alias="toolRef") + name: str + title: Optional[str] = None + description: str + input_schema: Optional[Dict[str, Any]] = Field(default=None, alias="inputSchema") + output_schema: Optional[Dict[str, Any]] = Field(default=None, alias="outputSchema") + annotations: WebMCPAnnotations + declarative: bool + backend_node_id: Optional[int] = Field(default=None, alias="backendNodeId") + source: WebMCPSource + + +class WebMCPToolsResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + tools: List[WebMCPTool] + native_supported: bool = Field(alias="nativeSupported") + truncated: bool + + +WebMCPInvokeStatus = Literal["completed", "error", "canceled", "awaiting_submission"] +WebMCPInvocationStatus = Literal[ + "running", + "awaiting_submission", + "completed", + "error", + "canceled", + "outcome_unknown", +] + + +class WebMCPInvokeResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + invocation_id: Optional[str] = Field(default=None, alias="invocationId") + status: WebMCPInvokeStatus + output: Any = None + error_text: Optional[str] = Field(default=None, alias="errorText") + output_bytes: int = Field(alias="outputBytes") + output_truncated: bool = Field(default=False, alias="outputTruncated") + output_preview: Optional[str] = Field(default=None, alias="outputPreview") + untrusted_content: bool = Field(alias="untrustedContent") + duration_ms: int = Field(alias="durationMs") + + +class WebMCPInvocationError(BaseModel): + code: str + message: str + + +class WebMCPInvocation(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + invocation_id: str = Field(alias="invocationId") + tool_ref: str = Field(alias="toolRef") + status: WebMCPInvocationStatus + cancellation_requested: bool = Field(alias="cancellationRequested") + created_at: datetime = Field(alias="createdAt") + expires_at: Optional[datetime] = Field(default=None, alias="expiresAt") + result: Optional[WebMCPInvokeResult] = None + error: Optional[WebMCPInvocationError] = None diff --git a/hyperbrowser/transport/async_transport.py b/hyperbrowser/transport/async_transport.py index 10f3474a..53267064 100644 --- a/hyperbrowser/transport/async_transport.py +++ b/hyperbrowser/transport/async_transport.py @@ -64,6 +64,7 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse: ) return APIResponse.from_status(response.status_code) except httpx.HTTPStatusError as e: + error_data = None try: error_data = response.json() message = error_data.get("message") or error_data.get("error") or str(e) @@ -71,6 +72,13 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse: message = str(e) raise HyperbrowserError( message, + code=( + error_data.get("code") + if isinstance(error_data, dict) + and isinstance(error_data.get("code"), str) + else None + ), + details=error_data, status_code=response.status_code, response=response, original_error=e, @@ -108,15 +116,23 @@ async def post( ) async def get( - self, url: str, params: Optional[dict] = None, follow_redirects: bool = False + self, + url: str, + params: Optional[dict] = None, + follow_redirects: bool = False, + *, + timeout: Optional[float] = None, ) -> APIResponse: if params: params = {k: v for k, v in params.items() if v is not None} + kwargs = {} + if timeout is not None: + kwargs["timeout"] = timeout failed_attempt = 1 while True: try: response = await self.client.get( - url, params=params, follow_redirects=follow_redirects + url, params=params, follow_redirects=follow_redirects, **kwargs ) return await self._handle_response(response) except BaseException as cause: diff --git a/hyperbrowser/transport/base.py b/hyperbrowser/transport/base.py index fee99f95..d25ad655 100644 --- a/hyperbrowser/transport/base.py +++ b/hyperbrowser/transport/base.py @@ -55,7 +55,14 @@ def post( pass @abstractmethod - def get(self, url: str, params: Optional[dict] = None) -> APIResponse: + def get( + self, + url: str, + params: Optional[dict] = None, + follow_redirects: bool = False, + *, + timeout: Optional[float] = None, + ) -> APIResponse: pass @abstractmethod diff --git a/hyperbrowser/transport/sync.py b/hyperbrowser/transport/sync.py index 29067c14..9a5ebd9e 100644 --- a/hyperbrowser/transport/sync.py +++ b/hyperbrowser/transport/sync.py @@ -42,6 +42,7 @@ def _handle_response(self, response: httpx.Response) -> APIResponse: ) return APIResponse.from_status(response.status_code) except httpx.HTTPStatusError as e: + error_data = None try: error_data = response.json() message = error_data.get("message") or error_data.get("error") or str(e) @@ -49,6 +50,13 @@ def _handle_response(self, response: httpx.Response) -> APIResponse: message = str(e) raise HyperbrowserError( message, + code=( + error_data.get("code") + if isinstance(error_data, dict) + and isinstance(error_data.get("code"), str) + else None + ), + details=error_data, status_code=response.status_code, response=response, original_error=e, @@ -89,15 +97,23 @@ def post( ) def get( - self, url: str, params: Optional[dict] = None, follow_redirects: bool = False + self, + url: str, + params: Optional[dict] = None, + follow_redirects: bool = False, + *, + timeout: Optional[float] = None, ) -> APIResponse: if params: params = {k: v for k, v in params.items() if v is not None} + kwargs = {} + if timeout is not None: + kwargs["timeout"] = timeout failed_attempt = 1 while True: try: response = self.client.get( - url, params=params, follow_redirects=follow_redirects + url, params=params, follow_redirects=follow_redirects, **kwargs ) return self._handle_response(response) except BaseException as cause: diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index ce1204de..20aa14dd 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -5,6 +5,11 @@ as JSON Schema objects, untouched. """ +from .webmcp import ( + WebMCPInvokeParams, + WebMCPStartParams, + WebMCPResultParams, +) from ._json import ( JSONSchema, JSONSchemaInput, @@ -178,6 +183,10 @@ __all__ = [ + "WebMCPInvokeParams", + "WebMCPStartParams", + "WebMCPResultParams", + "JSONSchema", "JSONSchemaInput", "JSONSchemaObject", diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index 787d204d..a5b8d56a 100644 --- a/hyperbrowser/types/session.py +++ b/hyperbrowser/types/session.py @@ -117,6 +117,7 @@ class StartSessionFromSnapshotParams(TypedDict): class CreateSessionParams(TypedDict, total=False): """Configuration for creating a browser session.""" + enable_web_mcp: Optional[bool] use_ultra_stealth: bool use_stealth: bool use_proxy: bool diff --git a/hyperbrowser/types/webmcp.py b/hyperbrowser/types/webmcp.py new file mode 100644 index 00000000..59728e95 --- /dev/null +++ b/hyperbrowser/types/webmcp.py @@ -0,0 +1,22 @@ +"""Typed dictionary requests for session WebMCP tools.""" + +from typing import Any, Dict +from typing_extensions import Required, TypedDict + + +class WebMCPInvokeParams(TypedDict, total=False): + """Blocking invocation; timeout_seconds is 1–120 (default 60).""" + + tool_ref: Required[str] + input: Dict[str, Any] + timeout_seconds: int + + +class WebMCPStartParams(WebMCPInvokeParams): + """Start an invocation; timeout_seconds is 1–3600 (default 300).""" + + +class WebMCPResultParams(TypedDict, total=False): + """Wait at most 0–30 seconds (default 0) for an invocation result.""" + + wait_seconds: int diff --git a/pyproject.toml b/pyproject.toml index fc850c00..2af1631f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "1.10.0" +version = "1.11.0" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" diff --git a/tests/test_webmcp.py b/tests/test_webmcp.py new file mode 100644 index 00000000..5e2cf5a5 --- /dev/null +++ b/tests/test_webmcp.py @@ -0,0 +1,319 @@ +import asyncio +import json +from datetime import datetime, timezone + +import httpx +import pytest +from pydantic import ValidationError + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models import ( + CreateSessionParams, + SessionLaunchState, + WebMCPInvokeParams, + WebMCPResultParams, + WebMCPStartParams, +) + +ID = "0123456789abcdef01234567" +HANDLE = { + "invocationId": ID, + "toolRef": "document-tool", + "status": "running", + "cancellationRequested": False, + "createdAt": "2026-10-06T12:00:00Z", +} +RESULT = { + "status": "completed", + "output": {"snake_key": {"CamelKey": [None, 42]}}, + "outputBytes": 37, + "untrustedContent": True, + "durationMs": 12, +} + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def sdk(request): + """Exercise the same wire contract through both public clients and transports.""" + is_async = request.param + loop = asyncio.new_event_loop() + client = (AsyncHyperbrowser if is_async else Hyperbrowser)(api_key="test-key") + calls = [] + outcomes = [] + + def handler(req): + calls.append(req) + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + def call(method, *args, **kwargs): + result = method(*args, **kwargs) + return loop.run_until_complete(result) if is_async else result + + call(client.transport.close) + client.transport.client = (httpx.AsyncClient if is_async else httpx.Client)( + transport=httpx.MockTransport(handler), + headers={"x-api-key": "test-key"}, + timeout=30, + ) + if is_async: + client.transport._closed = False + yield client, call, calls, outcomes + call(client.close) + loop.close() + + +def respond(outcomes, payload, status=200): + outcomes.append(httpx.Response(status, json=payload)) + + +def test_session_opt_in_and_launch_state(sdk): + client, call, calls, outcomes = sdk + # Session creation defaults and legacy request behavior are covered by the + # existing compatibility suite; this checks the new opt-in on the wire. + for params in [ + {}, + {"enable_web_mcp": False}, + CreateSessionParams(enable_web_mcp=True), + ]: + respond(outcomes, {"message": "fixture stops before parsing"}, 400) + with pytest.raises(HyperbrowserError): + call(client.sessions.create, params) + assert "enableWebMcp" not in json.loads(calls[0].content) + assert json.loads(calls[1].content)["enableWebMcp"] is False + assert json.loads(calls[2].content)["enableWebMcp"] is True + assert SessionLaunchState(enableWebMcp=True).enable_web_mcp is True + assert SessionLaunchState().enable_web_mcp is None + + +def test_discovery_metadata(sdk): + client, call, calls, outcomes = sdk + respond( + outcomes, + { + "nativeSupported": True, + "truncated": True, + "tools": [ + { + "toolRef": "document-tool", + "name": "search", + "description": "Search", + "declarative": True, + "backendNodeId": 123, + "inputSchema": {"properties": {"user_query": {"type": "string"}}}, + "outputSchema": {"type": "object"}, + "annotations": { + "readOnly": True, + "untrustedContent": True, + "consequential": False, + "autosubmit": False, + "openWorld": True, + }, + "source": { + "provider": "native", + "tabId": "tab", + "pageUrl": "https://page.test", + "pageTitle": "Page", + "frame": { + "id": "frame", + "url": "https://page.test/frame", + "isMainFrame": False, + }, + }, + } + ], + }, + ) + result = call(client.sessions.webmcp.list_tools, "session /#") + assert result.native_supported and result.truncated + assert result.tools[0].backend_node_id == 123 + assert result.tools[0].input_schema["properties"]["user_query"] == { + "type": "string" + } + assert result.tools[0].annotations.open_world + assert not result.tools[0].source.frame.is_main_frame + assert calls[0].url.raw_path == b"/api/session/session%20%2F%23/webmcp/tools" + assert calls[0].extensions["timeout"]["read"] == 40 + assert calls[0].headers["x-api-key"] == "test-key" + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_invoke_request_parity_and_json_keys(sdk, legacy): + client, call, calls, outcomes = sdk + respond(outcomes, RESULT) + params = { + "tool_ref": "document-tool", + "input": {"snake_key": {"CamelKey": None}}, + "timeout_seconds": 120, + } + result = call( + client.sessions.webmcp.invoke, + "s", + WebMCPInvokeParams(**params) if legacy else params, + ) + assert result.output == RESULT["output"] + assert result.untrusted_content + assert json.loads(calls[0].content) == { + "toolRef": "document-tool", + "input": params["input"], + "timeoutSeconds": 120, + } + assert calls[0].extensions["timeout"]["read"] == 155 + assert client.transport.client.timeout.read == 30 + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_start_poll_cancel_and_retention(sdk, legacy): + client, call, calls, outcomes = sdk + respond(outcomes, HANDLE, 202) + respond(outcomes, {**HANDLE, "status": "awaiting_submission"}) + respond( + outcomes, + { + **HANDLE, + "status": "completed", + "cancellationRequested": True, + "result": RESULT, + "expiresAt": "2026-10-06T12:10:00Z", + }, + ) + start = {"tool_ref": "document-tool", "timeout_seconds": 3600} + invocation = call( + client.sessions.webmcp.start, + "s", + WebMCPStartParams(**start) if legacy else start, + ) + assert invocation.status == "running" + assert invocation.created_at == datetime(2026, 10, 6, 12, tzinfo=timezone.utc) + assert calls[-1].extensions["timeout"]["read"] == 40 + params = WebMCPResultParams(wait_seconds=30) if legacy else {"wait_seconds": 30} + pending = call( + client.sessions.webmcp.get_result, "s", invocation.invocation_id, params + ) + assert pending.status == "awaiting_submission" + assert dict(calls[-1].url.params) == {"waitSeconds": "30"} + assert calls[-1].extensions["timeout"]["read"] == 50 + canceled = call(client.sessions.webmcp.cancel, "s", invocation.invocation_id) + assert canceled.status == "completed" # Completion can win cancellation. + assert canceled.cancellation_requested + assert canceled.result.output == RESULT["output"] + assert canceled.expires_at is not None + assert [(req.method, req.url.path) for req in calls] == [ + ("POST", "/api/session/s/webmcp/invocations"), + ("GET", f"/api/session/s/webmcp/invocations/{ID}"), + ("POST", f"/api/session/s/webmcp/invocations/{ID}/cancel"), + ] + + +@pytest.mark.parametrize("method", ["invoke", "start", "cancel"]) +@pytest.mark.parametrize("failure", ["network", "api"]) +def test_mutations_never_retry_and_preserve_error_details(sdk, method, failure): + client, call, calls, outcomes = sdk + payload = { + "code": "outcome_unknown", + "message": "Unknown outcome", + "invocationId": ID, + } + if failure == "network": + outcomes.append(httpx.ReadError("socket lost")) + else: + respond(outcomes, payload, 504) + args = ["s", ID if method == "cancel" else {"tool_ref": "t"}] + with pytest.raises(HyperbrowserError) as raised: + call(getattr(client.sessions.webmcp, method), *args) + assert len(calls) == 1 + if failure == "api": + assert raised.value.code == "outcome_unknown" + assert raised.value.details == payload + assert raised.value.status_code == 504 + + +def test_missing_handle_is_not_retried(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, {"code": "invocation_not_found", "message": "Expired"}, 404) + with pytest.raises(HyperbrowserError) as raised: + call(client.sessions.webmcp.get_result, "s", ID) + assert raised.value.code == "invocation_not_found" + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "payload", + [ + {**RESULT, "output": None}, + { + k: v + for k, v in { + **RESULT, + "outputTruncated": True, + "outputBytes": 2_000_000, + "outputPreview": "preview", + }.items() + if k != "output" + }, + {**RESULT, "status": "awaiting_submission", "invocationId": ID}, + {**RESULT, "status": "error", "errorText": "Page error"}, + ], +) +def test_result_shapes(sdk, payload): + client, call, calls, outcomes = sdk + respond(outcomes, payload) + result = call(client.sessions.webmcp.invoke, "s", {"tool_ref": "t"}) + assert result.output == payload.get("output") + assert result.status == payload["status"] + assert result.output_truncated == payload.get("outputTruncated", False) + assert result.invocation_id == payload.get("invocationId") + + +def test_larger_client_timeout_is_respected(sdk): + client, call, calls, outcomes = sdk + client.timeout = 200 + for method, args, payload in [ + ( + "list_tools", + ["s"], + {"tools": [], "nativeSupported": False, "truncated": False}, + ), + ("invoke", ["s", {"tool_ref": "t"}], RESULT), + ("start", ["s", {"tool_ref": "t"}], HANDLE), + ("get_result", ["s", ID], HANDLE), + ("cancel", ["s", ID], HANDLE), + ]: + respond(outcomes, payload) + call(getattr(client.sessions.webmcp, method), *args) + assert calls[-1].extensions["timeout"]["read"] == 200 + + +@pytest.mark.parametrize( + "method,params", + [ + ("invoke", {"tool_ref": "t", "timeout_seconds": 121}), + ("invoke", {"tool_ref": "t", "timeout_seconds": 0}), + ("invoke", {"tool_ref": "t", "timeout_seconds": 1.5}), + ("invoke", {"tool_ref": "t", "input": []}), + ("invoke", {"tool_ref": ""}), + ("start", {"tool_ref": "t", "timeout_seconds": 3601}), + ("get_result", {"wait_seconds": 31}), + ("get_result", {"wait_seconds": -1}), + ], +) +def test_invalid_requests_fail_before_http(sdk, method, params): + client, call, calls, outcomes = sdk + args = ["s", ID, params] if method == "get_result" else ["s", params] + with pytest.raises(ValidationError): + call(getattr(client.sessions.webmcp, method), *args) + assert not calls + + +@pytest.mark.parametrize("status", ["error", "outcome_unknown"]) +def test_terminal_handles_without_result(sdk, status): + client, call, calls, outcomes = sdk + error = {"code": "outcome_unknown", "message": "Connection lost"} + respond(outcomes, {**HANDLE, "status": status, "error": error}) + handle = call(client.sessions.webmcp.get_result, "s", ID) + assert handle.status == status + assert handle.result is None + assert handle.error.code == "outcome_unknown" diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index d59ff8a8..efa758af 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -88,3 +88,15 @@ async def invalid_async_requests(client: AsyncHyperbrowser) -> None: await client.agents.browser_use.start( {"task": "Browse", "output_model_schema": True} # M,P ) + + +def invalid_webmcp_requests(client: Hyperbrowser) -> None: + client.sessions.webmcp.invoke("s", {"toolRef": "t"}) # M,P + client.sessions.webmcp.start("s", {"input": {}}) # M,P + client.sessions.webmcp.invoke("s", {"tool_ref": "t", "input": []}) # M,P + client.sessions.webmcp.get_result("s", "i", {"wait_seconds": "30"}) # M,P + + +async def invalid_async_webmcp_requests(client: AsyncHyperbrowser) -> None: + await client.sessions.webmcp.invoke("s", {"tool_ref": "t", "timeout_seconds": "60"}) # M,P + await client.sessions.webmcp.get_result("s", "i", {"waitSeconds": 30}) # M,P diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index e70c3fc0..a109d569 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -466,3 +466,24 @@ async def valid_async_process_collection(client: AsyncHyperbrowser) -> None: ) await process.wait(timeout_sec=10) await process.disconnect() + + +def valid_webmcp_requests(client: Hyperbrowser) -> None: + from hyperbrowser.models import WebMCPInvokeParams, WebMCPStartParams, WebMCPResultParams + client.sessions.create({"enable_web_mcp": True}) + tools = client.sessions.webmcp.list_tools("session") + tool = tools.tools[0] + client.sessions.webmcp.invoke("session", {"tool_ref": tool.tool_ref, "input": {"customKey": 1}}) + client.sessions.webmcp.invoke("session", WebMCPInvokeParams(tool_ref=tool.tool_ref)) + handle = client.sessions.webmcp.start("session", WebMCPStartParams(tool_ref=tool.tool_ref, timeout_seconds=600)) + client.sessions.webmcp.get_result("session", handle.invocation_id, WebMCPResultParams(wait_seconds=30)) + client.sessions.webmcp.cancel("session", handle.invocation_id) + + +async def valid_async_webmcp_requests(client: AsyncHyperbrowser) -> None: + await client.sessions.create({"enable_web_mcp": True}) + tools = await client.sessions.webmcp.list_tools("session") + await client.sessions.webmcp.invoke("session", {"tool_ref": tools.tools[0].tool_ref}) + handle = await client.sessions.webmcp.start("session", {"tool_ref": tools.tools[0].tool_ref, "timeout_seconds": 600}) + await client.sessions.webmcp.get_result("session", handle.invocation_id, {"wait_seconds": 30}) + await client.sessions.webmcp.cancel("session", handle.invocation_id)