Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions hyperbrowser/client/managers/async_manager/session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import warnings

from .webmcp import WebMCPManager
from collections.abc import Mapping
from typing import IO, List, Optional, Union, overload

Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions hyperbrowser/client/managers/async_manager/webmcp.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions hyperbrowser/client/managers/sync_manager/session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import warnings

from .webmcp import WebMCPManager
from collections.abc import Mapping
from typing import IO, List, Optional, Union, overload

Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions hyperbrowser/client/managers/sync_manager/webmcp.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions hyperbrowser/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions hyperbrowser/models/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
)
Expand Down
Loading