smooth-py 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of smooth-py might be problematic. Click here for more details.
- smooth/__init__.py +40 -14
- smooth/mcp/__init__.py +9 -0
- smooth/mcp/server.py +570 -0
- {smooth_py-0.1.4.dist-info → smooth_py-0.2.0.dist-info}/METADATA +78 -1
- smooth_py-0.2.0.dist-info/RECORD +6 -0
- smooth_py-0.1.4.dist-info/RECORD +0 -4
- {smooth_py-0.1.4.dist-info → smooth_py-0.2.0.dist-info}/WHEEL +0 -0
smooth/__init__.py
CHANGED
|
@@ -5,10 +5,7 @@ import logging
|
|
|
5
5
|
import os
|
|
6
6
|
import time
|
|
7
7
|
import urllib.parse
|
|
8
|
-
from typing import
|
|
9
|
-
Any,
|
|
10
|
-
Literal,
|
|
11
|
-
)
|
|
8
|
+
from typing import Any, Literal, Type
|
|
12
9
|
|
|
13
10
|
import httpx
|
|
14
11
|
import requests
|
|
@@ -27,10 +24,7 @@ BASE_URL = "https://api2.circlemind.co/api/"
|
|
|
27
24
|
def _encode_url(url: str, interactive: bool = True, embed: bool = False) -> str:
|
|
28
25
|
parsed_url = urllib.parse.urlparse(url)
|
|
29
26
|
params = urllib.parse.parse_qs(parsed_url.query)
|
|
30
|
-
params.update({
|
|
31
|
-
"interactive": "true" if interactive else "false",
|
|
32
|
-
"embed": "true" if embed else "false"
|
|
33
|
-
})
|
|
27
|
+
params.update({"interactive": "true" if interactive else "false", "embed": "true" if embed else "false"})
|
|
34
28
|
return urllib.parse.urlunparse(parsed_url._replace(query=urllib.parse.urlencode(params)))
|
|
35
29
|
|
|
36
30
|
|
|
@@ -54,6 +48,9 @@ class TaskRequest(BaseModel):
|
|
|
54
48
|
"""Run task request model."""
|
|
55
49
|
|
|
56
50
|
task: str = Field(description="The task to run.")
|
|
51
|
+
response_model: dict[str, Any] | None = Field(
|
|
52
|
+
default=None, description="If provided, the schema describing the desired output structure. Default is None"
|
|
53
|
+
)
|
|
57
54
|
agent: Literal["smooth"] = Field(default="smooth", description="The agent to use for the task.")
|
|
58
55
|
max_steps: int = Field(default=32, ge=2, le=128, description="Maximum number of steps the agent can take (min 2, max 128).")
|
|
59
56
|
device: Literal["desktop", "mobile"] = Field(default="mobile", description="Device type for the task. Default is mobile.")
|
|
@@ -80,13 +77,14 @@ class BrowserSessionRequest(BaseModel):
|
|
|
80
77
|
default=None,
|
|
81
78
|
description=("The session ID to open in the browser. If None, a new session will be created with a random name."),
|
|
82
79
|
)
|
|
80
|
+
live_view: bool | None = Field(default=True, description="Request a live URL to interact with the browser session.")
|
|
83
81
|
|
|
84
82
|
|
|
85
83
|
class BrowserSessionResponse(BaseModel):
|
|
86
84
|
"""Browser session response model."""
|
|
87
85
|
|
|
88
|
-
live_url: str = Field(description="The live URL to interact with the browser session.")
|
|
89
86
|
session_id: str = Field(description="The ID of the browser session associated with the opened browser instance.")
|
|
87
|
+
live_url: str | None = Field(default=None, description="The live URL to interact with the browser session.")
|
|
90
88
|
|
|
91
89
|
|
|
92
90
|
class BrowserSessionsResponse(BaseModel):
|
|
@@ -176,7 +174,9 @@ class BrowserSessionHandle(BaseModel):
|
|
|
176
174
|
|
|
177
175
|
def live_url(self, interactive: bool = True, embed: bool = False):
|
|
178
176
|
"""Returns the live URL for the browser session."""
|
|
179
|
-
|
|
177
|
+
if self.browser_session.live_url:
|
|
178
|
+
return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
|
|
179
|
+
return None
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
class TaskHandle:
|
|
@@ -286,6 +286,7 @@ class SmoothClient(BaseClient):
|
|
|
286
286
|
def run(
|
|
287
287
|
self,
|
|
288
288
|
task: str,
|
|
289
|
+
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
289
290
|
agent: Literal["smooth"] = "smooth",
|
|
290
291
|
max_steps: int = 32,
|
|
291
292
|
device: Literal["desktop", "mobile"] = "mobile",
|
|
@@ -303,6 +304,7 @@ class SmoothClient(BaseClient):
|
|
|
303
304
|
|
|
304
305
|
Args:
|
|
305
306
|
task: The task to run.
|
|
307
|
+
response_model: If provided, the schema describing the desired output structure.
|
|
306
308
|
agent: The agent to use for the task.
|
|
307
309
|
max_steps: Maximum number of steps the agent can take (max 64).
|
|
308
310
|
device: Device type for the task. Default is mobile.
|
|
@@ -321,6 +323,7 @@ class SmoothClient(BaseClient):
|
|
|
321
323
|
"""
|
|
322
324
|
payload = TaskRequest(
|
|
323
325
|
task=task,
|
|
326
|
+
response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
|
|
324
327
|
agent=agent,
|
|
325
328
|
max_steps=max_steps,
|
|
326
329
|
device=device,
|
|
@@ -335,11 +338,12 @@ class SmoothClient(BaseClient):
|
|
|
335
338
|
|
|
336
339
|
return TaskHandle(initial_response.id, self)
|
|
337
340
|
|
|
338
|
-
def open_session(self, session_id: str | None = None) -> BrowserSessionHandle:
|
|
341
|
+
def open_session(self, session_id: str | None = None, live_view: bool = True) -> BrowserSessionHandle:
|
|
339
342
|
"""Gets an interactive browser instance.
|
|
340
343
|
|
|
341
344
|
Args:
|
|
342
345
|
session_id: The session ID to associate with the browser. If None, a new session will be created.
|
|
346
|
+
live_view: Whether to enable live view for the session.
|
|
343
347
|
|
|
344
348
|
Returns:
|
|
345
349
|
The browser session details, including the live URL.
|
|
@@ -350,7 +354,7 @@ class SmoothClient(BaseClient):
|
|
|
350
354
|
try:
|
|
351
355
|
response = self._session.post(
|
|
352
356
|
f"{self.base_url}/browser/session",
|
|
353
|
-
json=BrowserSessionRequest(session_id=session_id).model_dump(exclude_none=True),
|
|
357
|
+
json=BrowserSessionRequest(session_id=session_id, live_view=live_view).model_dump(exclude_none=True),
|
|
354
358
|
)
|
|
355
359
|
data = self._handle_response(response)
|
|
356
360
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -447,6 +451,7 @@ class AsyncTaskHandle:
|
|
|
447
451
|
|
|
448
452
|
raise TimeoutError(f"Recording URL not available for task {self.id}.")
|
|
449
453
|
|
|
454
|
+
|
|
450
455
|
class SmoothAsyncClient(BaseClient):
|
|
451
456
|
"""An asynchronous client for the API."""
|
|
452
457
|
|
|
@@ -489,6 +494,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
489
494
|
async def run(
|
|
490
495
|
self,
|
|
491
496
|
task: str,
|
|
497
|
+
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
492
498
|
agent: Literal["smooth"] = "smooth",
|
|
493
499
|
max_steps: int = 32,
|
|
494
500
|
device: Literal["desktop", "mobile"] = "mobile",
|
|
@@ -506,6 +512,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
506
512
|
|
|
507
513
|
Args:
|
|
508
514
|
task: The task to run.
|
|
515
|
+
response_model: If provided, the schema describing the desired output structure.
|
|
509
516
|
agent: The agent to use for the task.
|
|
510
517
|
max_steps: Maximum number of steps the agent can take (max 64).
|
|
511
518
|
device: Device type for the task. Default is mobile.
|
|
@@ -526,6 +533,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
526
533
|
"""
|
|
527
534
|
payload = TaskRequest(
|
|
528
535
|
task=task,
|
|
536
|
+
response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
|
|
529
537
|
agent=agent,
|
|
530
538
|
max_steps=max_steps,
|
|
531
539
|
device=device,
|
|
@@ -540,11 +548,12 @@ class SmoothAsyncClient(BaseClient):
|
|
|
540
548
|
initial_response = await self._submit_task(payload)
|
|
541
549
|
return AsyncTaskHandle(initial_response.id, self)
|
|
542
550
|
|
|
543
|
-
async def open_session(self, session_id: str | None = None) -> BrowserSessionHandle:
|
|
551
|
+
async def open_session(self, session_id: str | None = None, live_view: bool = True) -> BrowserSessionHandle:
|
|
544
552
|
"""Opens an interactive browser instance asynchronously.
|
|
545
553
|
|
|
546
554
|
Args:
|
|
547
555
|
session_id: The session ID to associate with the browser.
|
|
556
|
+
live_view: Whether to enable live view for the session.
|
|
548
557
|
|
|
549
558
|
Returns:
|
|
550
559
|
The browser session details, including the live URL.
|
|
@@ -555,7 +564,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
555
564
|
try:
|
|
556
565
|
response = await self._client.post(
|
|
557
566
|
f"{self.base_url}/browser/session",
|
|
558
|
-
json=BrowserSessionRequest(session_id=session_id).model_dump(exclude_none=True),
|
|
567
|
+
json=BrowserSessionRequest(session_id=session_id, live_view=live_view).model_dump(exclude_none=True),
|
|
559
568
|
)
|
|
560
569
|
data = self._handle_response(response)
|
|
561
570
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -592,3 +601,20 @@ class SmoothAsyncClient(BaseClient):
|
|
|
592
601
|
async def close(self):
|
|
593
602
|
"""Closes the async client session."""
|
|
594
603
|
await self._client.aclose()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# Export public API
|
|
607
|
+
__all__ = [
|
|
608
|
+
"SmoothClient",
|
|
609
|
+
"SmoothAsyncClient",
|
|
610
|
+
"TaskHandle",
|
|
611
|
+
"AsyncTaskHandle",
|
|
612
|
+
"BrowserSessionHandle",
|
|
613
|
+
"TaskRequest",
|
|
614
|
+
"TaskResponse",
|
|
615
|
+
"BrowserSessionRequest",
|
|
616
|
+
"BrowserSessionResponse",
|
|
617
|
+
"BrowserSessionsResponse",
|
|
618
|
+
"ApiError",
|
|
619
|
+
"TimeoutError",
|
|
620
|
+
]
|
smooth/mcp/__init__.py
ADDED
smooth/mcp/server.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Smooth SDK MCP Server Implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the SmoothMCP class that integrates the Smooth SDK
|
|
4
|
+
with the Model Context Protocol for AI assistant interactions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
from typing import Annotated, Any, Dict, Literal, Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from fastmcp import Context, FastMCP
|
|
13
|
+
from fastmcp.exceptions import ResourceError
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
except ImportError as e:
|
|
16
|
+
raise ImportError("FastMCP is required for MCP functionality. Install with: pip install smooth-py[mcp]") from e
|
|
17
|
+
|
|
18
|
+
from .. import ApiError, SmoothAsyncClient, TimeoutError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SmoothMCP:
|
|
22
|
+
"""MCP server for Smooth SDK browser automation agent.
|
|
23
|
+
|
|
24
|
+
This class provides a Model Context Protocol server that exposes
|
|
25
|
+
Smooth SDK functionality to AI assistants and other MCP clients.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
from smooth.mcp import SmoothMCP
|
|
30
|
+
|
|
31
|
+
# Create and run the MCP server
|
|
32
|
+
mcp = SmoothMCP(api_key="your-api-key")
|
|
33
|
+
mcp.run() # Runs with STDIO transport by default
|
|
34
|
+
|
|
35
|
+
# Or run with HTTP transport
|
|
36
|
+
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
|
37
|
+
```
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: Optional[str] = None,
|
|
43
|
+
server_name: str = "Smooth Browser Agent",
|
|
44
|
+
base_url: Optional[str] = None,
|
|
45
|
+
):
|
|
46
|
+
"""Initialize the Smooth MCP server.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
api_key: Smooth API key. If not provided, will use CIRCLEMIND_API_KEY env var.
|
|
50
|
+
server_name: Name for the MCP server.
|
|
51
|
+
base_url: Base URL for the Smooth API (optional).
|
|
52
|
+
"""
|
|
53
|
+
self.api_key = api_key or os.getenv("CIRCLEMIND_API_KEY")
|
|
54
|
+
if not self.api_key:
|
|
55
|
+
raise ValueError("API key is required. Provide it directly or set CIRCLEMIND_API_KEY environment variable.")
|
|
56
|
+
|
|
57
|
+
self.base_url = base_url
|
|
58
|
+
self.server_name = server_name
|
|
59
|
+
|
|
60
|
+
# Initialize FastMCP server
|
|
61
|
+
self._mcp = FastMCP(server_name)
|
|
62
|
+
self._smooth_client: Optional[SmoothAsyncClient] = None
|
|
63
|
+
|
|
64
|
+
# Register tools and resources
|
|
65
|
+
self._register_tools()
|
|
66
|
+
self._register_resources()
|
|
67
|
+
|
|
68
|
+
async def _get_smooth_client(self) -> SmoothAsyncClient:
|
|
69
|
+
"""Get or create the Smooth client instance."""
|
|
70
|
+
if self._smooth_client is None:
|
|
71
|
+
kwargs = {"api_key": self.api_key}
|
|
72
|
+
if self.base_url:
|
|
73
|
+
kwargs["base_url"] = self.base_url
|
|
74
|
+
self._smooth_client = SmoothAsyncClient(**kwargs)
|
|
75
|
+
return self._smooth_client
|
|
76
|
+
|
|
77
|
+
def _register_tools(self):
|
|
78
|
+
"""Register MCP tools."""
|
|
79
|
+
|
|
80
|
+
@self._mcp.tool(
|
|
81
|
+
name="run_browser_task",
|
|
82
|
+
description=(
|
|
83
|
+
"Execute browser automation tasks using natural language descriptions. "
|
|
84
|
+
"Supports both desktop and mobile devices with optional profile state, recording, and advanced configuration."
|
|
85
|
+
),
|
|
86
|
+
annotations={"title": "Run Browser Task", "readOnlyHint": False, "destructiveHint": False, "openWorldHint": True},
|
|
87
|
+
)
|
|
88
|
+
async def run_browser_task(
|
|
89
|
+
ctx: Context,
|
|
90
|
+
task: Annotated[
|
|
91
|
+
str,
|
|
92
|
+
Field(
|
|
93
|
+
description=(
|
|
94
|
+
"Natural language description of the browser automation task to perform "
|
|
95
|
+
"(e.g., 'Go to Google and search for FastMCP', 'Fill out the contact form with test data at this url: ...')"
|
|
96
|
+
)
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
device: Annotated[
|
|
100
|
+
Literal["desktop", "mobile"],
|
|
101
|
+
Field(
|
|
102
|
+
description=(
|
|
103
|
+
"Device type for browser automation. Desktop provides full browser experience, "
|
|
104
|
+
"mobile uses a mobile viewport. Mobile is preferred as mobile web pages are lighter and easier to interact with"
|
|
105
|
+
)
|
|
106
|
+
),
|
|
107
|
+
] = "mobile",
|
|
108
|
+
max_steps: Annotated[
|
|
109
|
+
int,
|
|
110
|
+
Field(
|
|
111
|
+
description=(
|
|
112
|
+
"Maximum number of steps the agent can take to complete the task. "
|
|
113
|
+
"Higher values allow more complex multi-step workflows"
|
|
114
|
+
),
|
|
115
|
+
ge=2,
|
|
116
|
+
le=128,
|
|
117
|
+
),
|
|
118
|
+
] = 32,
|
|
119
|
+
enable_recording: Annotated[
|
|
120
|
+
bool,
|
|
121
|
+
Field(
|
|
122
|
+
description="Whether to record video of the task execution. Recordings can be used for debugging and verification"
|
|
123
|
+
),
|
|
124
|
+
] = True,
|
|
125
|
+
profile_id: Annotated[
|
|
126
|
+
Optional[str],
|
|
127
|
+
Field(
|
|
128
|
+
description=(
|
|
129
|
+
"Browser profile ID to pass login credentials to the agent. "
|
|
130
|
+
"The user must have already created and manually populated a profile and provide the profile ID."
|
|
131
|
+
)
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
134
|
+
# stealth_mode: Annotated[
|
|
135
|
+
# bool,
|
|
136
|
+
# Field(
|
|
137
|
+
# description=(
|
|
138
|
+
# "Run browser in stealth mode to avoid detection by anti-bot systems. "
|
|
139
|
+
# "Useful for accessing sites that block automated browsers"
|
|
140
|
+
# )
|
|
141
|
+
# ),
|
|
142
|
+
# ] = True,
|
|
143
|
+
# proxy_server: Annotated[
|
|
144
|
+
# Optional[str],
|
|
145
|
+
# Field(
|
|
146
|
+
# description=(
|
|
147
|
+
# "Proxy server URL to route browser traffic through. "
|
|
148
|
+
# "Must include protocol (e.g., 'http://proxy.example.com:8080')"
|
|
149
|
+
# )
|
|
150
|
+
# ),
|
|
151
|
+
# ] = None,
|
|
152
|
+
# proxy_username: Annotated[Optional[str], Field(description="Username for proxy server authentication")] = None,
|
|
153
|
+
# proxy_password: Annotated[Optional[str], Field(description="Password for proxy server authentication")] = None,
|
|
154
|
+
timeout: Annotated[
|
|
155
|
+
int,
|
|
156
|
+
Field(
|
|
157
|
+
description=(
|
|
158
|
+
"Maximum time to wait for task completion in seconds. Increase for complex tasks that may take longer."
|
|
159
|
+
" Max 15 minutes."
|
|
160
|
+
),
|
|
161
|
+
ge=30,
|
|
162
|
+
le=60*15,
|
|
163
|
+
),
|
|
164
|
+
] = 60*15,
|
|
165
|
+
) -> Dict[str, Any]:
|
|
166
|
+
"""Run a browser automation task using the Smooth SDK.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
ctx: MCP context for logging and communication
|
|
170
|
+
task: Natural language description of the task to perform
|
|
171
|
+
device: Device type ("desktop" or "mobile", default: "mobile")
|
|
172
|
+
max_steps: Maximum steps for the agent (2-128, default: 32)
|
|
173
|
+
enable_recording: Whether to record the execution (default: True)
|
|
174
|
+
profile_id: Optional browser profile ID to maintain state
|
|
175
|
+
stealth_mode: Run in stealth mode to avoid detection (default: False)
|
|
176
|
+
proxy_server: Proxy server URL (must include protocol)
|
|
177
|
+
proxy_username: Proxy authentication username
|
|
178
|
+
proxy_password: Proxy authentication password
|
|
179
|
+
timeout: Maximum time to wait for completion in seconds (default: 300)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Dictionary containing task results, status, and URLs
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
await ctx.info(f"Starting browser task: {task}")
|
|
186
|
+
|
|
187
|
+
# Validate device parameter
|
|
188
|
+
if device not in ["desktop", "mobile"]:
|
|
189
|
+
raise ValueError("Device must be 'desktop' or 'mobile'")
|
|
190
|
+
|
|
191
|
+
# Validate max_steps
|
|
192
|
+
if not (2 <= max_steps <= 128):
|
|
193
|
+
raise ValueError("max_steps must be between 2 and 128")
|
|
194
|
+
|
|
195
|
+
client = await self._get_smooth_client()
|
|
196
|
+
|
|
197
|
+
# Submit the task
|
|
198
|
+
task_handle = await client.run(
|
|
199
|
+
task=task,
|
|
200
|
+
device=device, # type: ignore
|
|
201
|
+
max_steps=max_steps,
|
|
202
|
+
enable_recording=enable_recording,
|
|
203
|
+
profile_id=profile_id,
|
|
204
|
+
stealth_mode=False,
|
|
205
|
+
proxy_server=None,
|
|
206
|
+
proxy_username=None,
|
|
207
|
+
proxy_password=None,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
await ctx.info(f"Task submitted with ID: {task_handle.id}")
|
|
211
|
+
|
|
212
|
+
# Wait for completion
|
|
213
|
+
await ctx.info("Waiting for task completion...")
|
|
214
|
+
result = await task_handle.result(timeout=timeout)
|
|
215
|
+
|
|
216
|
+
# Prepare response
|
|
217
|
+
response = {
|
|
218
|
+
"task_id": task_handle.id,
|
|
219
|
+
"status": result.status,
|
|
220
|
+
"output": result.output,
|
|
221
|
+
"credits_used": result.credits_used,
|
|
222
|
+
"device": result.device,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if result.recording_url:
|
|
226
|
+
response["recording_url"] = result.recording_url
|
|
227
|
+
await ctx.info(f"Recording available at: {result.recording_url}")
|
|
228
|
+
|
|
229
|
+
if result.status == "done":
|
|
230
|
+
await ctx.info("Task completed successfully!")
|
|
231
|
+
else:
|
|
232
|
+
await ctx.error(f"Task failed with status: {result.status}")
|
|
233
|
+
|
|
234
|
+
return response
|
|
235
|
+
|
|
236
|
+
except ApiError as e:
|
|
237
|
+
error_msg = f"Smooth API error: {e.detail}"
|
|
238
|
+
await ctx.error(error_msg)
|
|
239
|
+
raise Exception(error_msg) from None
|
|
240
|
+
except TimeoutError as e:
|
|
241
|
+
error_msg = f"Task timed out: {str(e)}"
|
|
242
|
+
await ctx.error(error_msg)
|
|
243
|
+
raise Exception(error_msg) from None
|
|
244
|
+
except Exception as e:
|
|
245
|
+
error_msg = f"Unexpected error: {str(e)}"
|
|
246
|
+
await ctx.error(error_msg)
|
|
247
|
+
raise Exception(error_msg) from None
|
|
248
|
+
|
|
249
|
+
@self._mcp.tool(
|
|
250
|
+
name="create_browser_profile",
|
|
251
|
+
description=(
|
|
252
|
+
"Create a new browser profile to store user credentials. "
|
|
253
|
+
"Returns a profile ID and live URL that need to be returned to the user to log in into various websites. "
|
|
254
|
+
"Once the user confirms they have logged in to the desired websites, the profile ID can be used in subsequent tasks "
|
|
255
|
+
" to access the user's authenticated state."
|
|
256
|
+
),
|
|
257
|
+
annotations={"title": "Create Browser profile", "readOnlyHint": False, "destructiveHint": False},
|
|
258
|
+
)
|
|
259
|
+
async def create_browser_profile(
|
|
260
|
+
ctx: Context,
|
|
261
|
+
profile_id: Annotated[
|
|
262
|
+
Optional[str],
|
|
263
|
+
Field(
|
|
264
|
+
description=(
|
|
265
|
+
"Optional custom profile ID. If not provided, a random one will be generated. "
|
|
266
|
+
"Use meaningful names for easier profile management"
|
|
267
|
+
)
|
|
268
|
+
),
|
|
269
|
+
] = None,
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""Create a new browser profile to maintain state between tasks.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
ctx: MCP context for logging and communication
|
|
275
|
+
profile_id: Optional custom profile ID. If not provided, a random one will be generated.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Dictionary containing profile details and live URL
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
await ctx.info("Creating browser profile" + (f" with ID: {profile_id}" if profile_id else ""))
|
|
282
|
+
|
|
283
|
+
client = await self._get_smooth_client()
|
|
284
|
+
profile_handle = await client.open_profile(profile_id=profile_id)
|
|
285
|
+
|
|
286
|
+
response = {
|
|
287
|
+
"profile_id": profile_handle.browser_profile.profile_id,
|
|
288
|
+
"live_url": profile_handle.browser_profile.live_url,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await ctx.info(f"Browser profile created: {response['profile_id']}")
|
|
292
|
+
await ctx.info(f"Live profile URL: {response['live_url']}")
|
|
293
|
+
|
|
294
|
+
return response
|
|
295
|
+
|
|
296
|
+
except ApiError as e:
|
|
297
|
+
error_msg = f"Failed to create browser profile: {e.detail}"
|
|
298
|
+
await ctx.error(error_msg)
|
|
299
|
+
raise Exception(error_msg) from None
|
|
300
|
+
except Exception as e:
|
|
301
|
+
error_msg = f"Unexpected error creating profile: {str(e)}"
|
|
302
|
+
await ctx.error(error_msg)
|
|
303
|
+
raise Exception(error_msg) from None
|
|
304
|
+
|
|
305
|
+
@self._mcp.tool(
|
|
306
|
+
name="list_browser_profiles",
|
|
307
|
+
description=(
|
|
308
|
+
"Retrieve a list of all saved browser profiles. "
|
|
309
|
+
"Shows profile IDs that can be passed to future tasks to access login credentials."
|
|
310
|
+
),
|
|
311
|
+
annotations={"title": "List Browser profiles", "readOnlyHint": True, "destructiveHint": False},
|
|
312
|
+
)
|
|
313
|
+
async def list_browser_profiles(ctx: Context) -> Dict[str, Any]:
|
|
314
|
+
"""List all existing browser profiles.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
ctx: MCP context for logging and communication
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Dictionary containing list of profile IDs
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
await ctx.info("Retrieving browser profiles...")
|
|
324
|
+
|
|
325
|
+
client = await self._get_smooth_client()
|
|
326
|
+
profiles = await client.list_profiles()
|
|
327
|
+
|
|
328
|
+
response = {
|
|
329
|
+
"profile_ids": profiles.profile_ids,
|
|
330
|
+
"total_profiles": len(profiles.profile_ids),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await ctx.info(f"Found {len(profiles.profile_ids)} browser profiles")
|
|
334
|
+
|
|
335
|
+
return response
|
|
336
|
+
|
|
337
|
+
except ApiError as e:
|
|
338
|
+
error_msg = f"Failed to list browser profiles: {e.detail}"
|
|
339
|
+
await ctx.error(error_msg)
|
|
340
|
+
raise Exception(error_msg) from None
|
|
341
|
+
except Exception as e:
|
|
342
|
+
error_msg = f"Unexpected error listing profiles: {str(e)}"
|
|
343
|
+
await ctx.error(error_msg)
|
|
344
|
+
raise Exception(error_msg) from None
|
|
345
|
+
|
|
346
|
+
@self._mcp.tool(
|
|
347
|
+
name="delete_browser_profile",
|
|
348
|
+
description=(
|
|
349
|
+
"Delete a browser profile and its associated credentials. "
|
|
350
|
+
"This permanently removes the profile and all associated data including cookies and cache."
|
|
351
|
+
),
|
|
352
|
+
annotations={"title": "Delete Browser profile", "readOnlyHint": False, "destructiveHint": True},
|
|
353
|
+
)
|
|
354
|
+
async def delete_browser_profile(
|
|
355
|
+
ctx: Context,
|
|
356
|
+
profile_id: Annotated[
|
|
357
|
+
str,
|
|
358
|
+
Field(
|
|
359
|
+
description=(
|
|
360
|
+
"The ID of the browser profile to delete. "
|
|
361
|
+
"Once deleted, this profile ID cannot be reused and all associated data will be lost"
|
|
362
|
+
),
|
|
363
|
+
min_length=1,
|
|
364
|
+
),
|
|
365
|
+
],
|
|
366
|
+
) -> Dict[str, Any]:
|
|
367
|
+
"""Delete a browser profile and clean up its data.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
ctx: MCP context for logging and communication
|
|
371
|
+
profile_id: The ID of the profile to delete
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dictionary confirming deletion
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
await ctx.info(f"Deleting browser profile: {profile_id}")
|
|
378
|
+
|
|
379
|
+
client = await self._get_smooth_client()
|
|
380
|
+
await client.delete_profile(profile_id)
|
|
381
|
+
|
|
382
|
+
response = {
|
|
383
|
+
"deleted_profile_id": profile_id,
|
|
384
|
+
"status": "deleted",
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await ctx.info(f"Browser profile {profile_id} deleted successfully")
|
|
388
|
+
|
|
389
|
+
return response
|
|
390
|
+
|
|
391
|
+
except ApiError as e:
|
|
392
|
+
error_msg = f"Failed to delete browser profile {profile_id}: {e.detail}"
|
|
393
|
+
await ctx.error(error_msg)
|
|
394
|
+
raise Exception(error_msg) from None
|
|
395
|
+
except Exception as e:
|
|
396
|
+
error_msg = f"Unexpected error deleting profile: {str(e)}"
|
|
397
|
+
await ctx.error(error_msg)
|
|
398
|
+
raise Exception(error_msg) from None
|
|
399
|
+
|
|
400
|
+
def _register_resources(self):
|
|
401
|
+
"""Register MCP resources with comprehensive documentation and dynamic capabilities."""
|
|
402
|
+
|
|
403
|
+
# Static API information with annotations
|
|
404
|
+
@self._mcp.resource(
|
|
405
|
+
"smooth://api/info",
|
|
406
|
+
description="Comprehensive information about the Smooth SDK MCP server and its capabilities",
|
|
407
|
+
annotations={"readOnlyHint": True, "idempotentHint": True},
|
|
408
|
+
tags={"documentation", "api"},
|
|
409
|
+
mime_type="text/markdown",
|
|
410
|
+
)
|
|
411
|
+
async def get_api_info(ctx: Context) -> str:
|
|
412
|
+
"""Get detailed information about the Smooth SDK and API."""
|
|
413
|
+
await ctx.info("Providing Smooth SDK API information")
|
|
414
|
+
return f"""# Smooth SDK MCP Server v{self._mcp.server_info.version}
|
|
415
|
+
|
|
416
|
+
This MCP server provides access to Smooth's browser automation capabilities through the Model Context Protocol.
|
|
417
|
+
|
|
418
|
+
## Server Information
|
|
419
|
+
- **Name**: {self._mcp.server_info.name}
|
|
420
|
+
- **Version**: {self._mcp.server_info.version}
|
|
421
|
+
- **Request ID**: {ctx.request_id}
|
|
422
|
+
- **Base URL**: {self.base_url or "Default (https://api2.circlemind.co/api/v1)"}
|
|
423
|
+
|
|
424
|
+
## Available Tools
|
|
425
|
+
|
|
426
|
+
### 🚀 run_browser_task
|
|
427
|
+
Execute browser automation tasks using natural language descriptions.
|
|
428
|
+
- **Device Support**: Desktop and mobile support
|
|
429
|
+
- **Profile Management**: Save and access user credentials
|
|
430
|
+
- **Recording**: Video capture of automation
|
|
431
|
+
|
|
432
|
+
### 🔧 create_browser_profile
|
|
433
|
+
Create persistent browser profiles to store and access credentials.
|
|
434
|
+
- **Live Viewing**: Real-time browser access for the user to enter their credentials
|
|
435
|
+
|
|
436
|
+
### 📋 list_browser_profiles
|
|
437
|
+
View all active browser profiles.
|
|
438
|
+
|
|
439
|
+
### 🗑️ delete_browser_profile
|
|
440
|
+
Permanently removes profile data when no longer needed.
|
|
441
|
+
- **Destructive**: Permanently removes profile data
|
|
442
|
+
|
|
443
|
+
## Configuration
|
|
444
|
+
|
|
445
|
+
Set your API key using the CIRCLEMIND_API_KEY environment variable:
|
|
446
|
+
```bash
|
|
447
|
+
export CIRCLEMIND_API_KEY="your-api-key-here"
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## Best Practices
|
|
451
|
+
|
|
452
|
+
1. **Use profiles**: Ask the user to create profiles for tasks requiring login and then use them.
|
|
453
|
+
2. **Descriptive Tasks**: Use clear, specific task descriptions.
|
|
454
|
+
3. **Error Handling**: Check task results for success/failure status
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
# Dynamic examples resource with path parameters
|
|
458
|
+
@self._mcp.resource(
|
|
459
|
+
"smooth://examples/{category}",
|
|
460
|
+
description="Get task examples for specific categories of browser automation",
|
|
461
|
+
annotations={"readOnlyHint": True, "idempotentHint": True},
|
|
462
|
+
tags={"examples", "templates", "dynamic"},
|
|
463
|
+
mime_type="text/markdown",
|
|
464
|
+
)
|
|
465
|
+
async def get_category_examples(category: str, ctx: Context) -> str:
|
|
466
|
+
"""Get examples for a specific category of browser automation tasks."""
|
|
467
|
+
await ctx.info(f"Providing examples for category: {category}")
|
|
468
|
+
|
|
469
|
+
examples_db = {
|
|
470
|
+
"scraping": """# Web Scraping Examples
|
|
471
|
+
|
|
472
|
+
## Basic Data Extraction
|
|
473
|
+
- "Go to example.com and extract all product prices"
|
|
474
|
+
- "Navigate to news.ycombinator.com and get the top 10 story titles"
|
|
475
|
+
- "Visit Wikipedia and search for 'artificial intelligence', then summarize the first paragraph"
|
|
476
|
+
|
|
477
|
+
## E-commerce Data
|
|
478
|
+
- "Extract product details from Amazon search results for 'wireless headphones'"
|
|
479
|
+
- "Get all customer reviews from the first product page"
|
|
480
|
+
- "Compare prices across multiple product listings"
|
|
481
|
+
|
|
482
|
+
## Social Media
|
|
483
|
+
- "Scrape the latest 20 tweets from a public Twitter profile"
|
|
484
|
+
- "Extract post engagement metrics from Instagram"
|
|
485
|
+
- "Get trending topics from Reddit front page"
|
|
486
|
+
""",
|
|
487
|
+
"forms": """# Form Automation Examples
|
|
488
|
+
|
|
489
|
+
## Contact Forms
|
|
490
|
+
- "Go to contact form at example.com and fill it with test data"
|
|
491
|
+
- "Fill out the newsletter signup with email: test@example.com"
|
|
492
|
+
- "Submit a support request with priority: high"
|
|
493
|
+
|
|
494
|
+
## Registration
|
|
495
|
+
- "Navigate to signup page and create an account with random details"
|
|
496
|
+
- "Complete user registration with name: John Doe, email: john@test.com"
|
|
497
|
+
- "Fill out profile information after account creation"
|
|
498
|
+
|
|
499
|
+
## Applications
|
|
500
|
+
- "Fill out the job application form with my resume information"
|
|
501
|
+
- "Complete the rental application with provided details"
|
|
502
|
+
- "Submit a loan application with financial information"
|
|
503
|
+
""",
|
|
504
|
+
"testing": """# Testing & QA Examples
|
|
505
|
+
|
|
506
|
+
## Functionality Testing
|
|
507
|
+
- "Test the checkout flow on our e-commerce site"
|
|
508
|
+
- "Verify all links on the homepage are working"
|
|
509
|
+
- "Check if the contact form is submitting properly"
|
|
510
|
+
|
|
511
|
+
## UI/UX Testing
|
|
512
|
+
- "Test responsive design by switching between desktop and mobile"
|
|
513
|
+
- "Verify navigation menu works on all pages"
|
|
514
|
+
- "Check loading times for key user journeys"
|
|
515
|
+
|
|
516
|
+
## Integration Testing
|
|
517
|
+
- "Test login flow with valid and invalid credentials"
|
|
518
|
+
- "Verify payment processing with test cards"
|
|
519
|
+
- "Check email verification workflow"
|
|
520
|
+
""",
|
|
521
|
+
"social": """# Social Media Automation Examples
|
|
522
|
+
|
|
523
|
+
## Content Management
|
|
524
|
+
- "Post a status update on Twitter" (requires login profile)
|
|
525
|
+
- "Upload an image to Instagram with caption" (requires login profile)
|
|
526
|
+
- "Share an article on LinkedIn with comment" (requires login profile)
|
|
527
|
+
|
|
528
|
+
## Engagement
|
|
529
|
+
- "Like the latest 10 posts in my feed" (requires login profile)
|
|
530
|
+
- "Reply to mentions and messages" (requires login profile)
|
|
531
|
+
- "Follow accounts based on specific criteria" (requires login profile)
|
|
532
|
+
|
|
533
|
+
## Analytics
|
|
534
|
+
- "Check latest posts performance metrics" (requires login profile)
|
|
535
|
+
- "Download engagement reports" (requires login profile)
|
|
536
|
+
- "Monitor brand mentions across platforms" (requires login profile)
|
|
537
|
+
""",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if category not in examples_db:
|
|
541
|
+
available_categories = ", ".join(examples_db.keys())
|
|
542
|
+
raise ResourceError(f"Category '{category}' not found. Available categories: {available_categories}")
|
|
543
|
+
|
|
544
|
+
return examples_db[category]
|
|
545
|
+
|
|
546
|
+
def run(self, **kwargs):
|
|
547
|
+
"""Run the MCP server.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
**kwargs: Arguments passed to FastMCP.run() such as transport, host, port, etc.
|
|
551
|
+
"""
|
|
552
|
+
try:
|
|
553
|
+
self._mcp.run(**kwargs)
|
|
554
|
+
finally:
|
|
555
|
+
# Clean up on exit
|
|
556
|
+
asyncio.run(self._cleanup())
|
|
557
|
+
|
|
558
|
+
async def _cleanup(self):
|
|
559
|
+
"""Clean up resources on shutdown."""
|
|
560
|
+
if self._smooth_client:
|
|
561
|
+
await self._smooth_client.close()
|
|
562
|
+
self._smooth_client = None
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def fastmcp_server(self) -> FastMCP:
|
|
566
|
+
"""Access to the underlying FastMCP server instance.
|
|
567
|
+
|
|
568
|
+
This allows advanced users to add custom tools or resources.
|
|
569
|
+
"""
|
|
570
|
+
return self._mcp
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: smooth-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Luca Pinchetti
|
|
6
6
|
Author-email: luca@circlemind.co
|
|
@@ -10,6 +10,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Provides-Extra: mcp
|
|
14
|
+
Requires-Dist: fastmcp (>=2.0.0,<3.0.0) ; extra == "mcp"
|
|
13
15
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
14
16
|
Requires-Dist: pydantic (>=2.11.7,<3.0.0)
|
|
15
17
|
Description-Content-Type: text/markdown
|
|
@@ -24,6 +26,7 @@ The Smooth Python SDK provides a convenient way to interact with the Smooth API
|
|
|
24
26
|
* **Task Management**: Easily run tasks and retrieve results upon completion.
|
|
25
27
|
* **Interactive Browser Sessions**: Get access to, interact with, and delete stateful browser sessions to manage your login credentials.
|
|
26
28
|
* **Advanced Task Configuration**: Customize task execution with options for device type, session recording, stealth mode, and proxy settings.
|
|
29
|
+
* **🆕 MCP Server**: Use the included Model Context Protocol server to integrate browser automation with AI assistants like Claude Desktop.
|
|
27
30
|
|
|
28
31
|
## Installation
|
|
29
32
|
|
|
@@ -33,6 +36,51 @@ You can install the Smooth Python SDK using pip:
|
|
|
33
36
|
pip install smooth-py
|
|
34
37
|
```
|
|
35
38
|
|
|
39
|
+
For MCP server functionality, also install FastMCP:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install fastmcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start Options
|
|
46
|
+
|
|
47
|
+
### Option 1: Direct SDK Usage
|
|
48
|
+
|
|
49
|
+
Use the SDK directly in your Python applications:
|
|
50
|
+
|
|
51
|
+
### Option 2: MCP Server (AI Assistant Integration)
|
|
52
|
+
|
|
53
|
+
Use the included MCP server to integrate browser automation with AI assistants:
|
|
54
|
+
|
|
55
|
+
#### Installation
|
|
56
|
+
```bash
|
|
57
|
+
# Install with MCP support
|
|
58
|
+
pip install smooth-py[mcp]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
#### Basic Usage
|
|
62
|
+
```python
|
|
63
|
+
from smooth.mcp import SmoothMCP
|
|
64
|
+
|
|
65
|
+
# Create and run the MCP server
|
|
66
|
+
mcp = SmoothMCP(api_key="your-api-key")
|
|
67
|
+
mcp.run() # STDIO transport for Claude Desktop
|
|
68
|
+
|
|
69
|
+
# Or with HTTP transport for web deployment
|
|
70
|
+
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### Standalone Script (Backward Compatible)
|
|
74
|
+
```bash
|
|
75
|
+
# Set your API key
|
|
76
|
+
export CIRCLEMIND_API_KEY="your-api-key-here"
|
|
77
|
+
|
|
78
|
+
# Run the MCP server
|
|
79
|
+
python mcp_server.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Then configure your AI assistant (like Claude Desktop) to use the MCP server. See [MCP_README.md](MCP_README.md) for detailed setup instructions.
|
|
83
|
+
|
|
36
84
|
## Authentication
|
|
37
85
|
|
|
38
86
|
The SDK requires an API key for authentication. You can provide the API key in two ways:
|
|
@@ -162,3 +210,32 @@ if __name__ == "__main__":
|
|
|
162
210
|
asyncio.run(main())
|
|
163
211
|
```
|
|
164
212
|
|
|
213
|
+
## MCP Server (AI Assistant Integration)
|
|
214
|
+
|
|
215
|
+
The Smooth SDK includes a Model Context Protocol (MCP) server that allows AI assistants like Claude Desktop or Cursor to perform browser automation tasks through natural language commands.
|
|
216
|
+
|
|
217
|
+
### Installation
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install smooth-py[mcp]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Basic Usage
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from smooth.mcp import SmoothMCP
|
|
227
|
+
|
|
228
|
+
# Create and run the MCP server
|
|
229
|
+
mcp = SmoothMCP(api_key="your-api-key")
|
|
230
|
+
mcp.run()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Example MCP Usage
|
|
234
|
+
|
|
235
|
+
Once configured, you can ask your MCP client to perform browser automation:
|
|
236
|
+
|
|
237
|
+
- "Please go to news.ycombinator.com and get the top 5 story titles"
|
|
238
|
+
- "Create a browser session, log into Gmail, and check for unread emails"
|
|
239
|
+
- "Go to Amazon and search for wireless headphones under $100"
|
|
240
|
+
- "Fill out the contact form at example.com with test data"
|
|
241
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
smooth/__init__.py,sha256=U32quTlDKOWQpsstfJ7pCgsOeUWYC3AAAzQDJkpBzBk,23629
|
|
2
|
+
smooth/mcp/__init__.py,sha256=0aJVFi2a8Ah3-5xtgyZ5UMbaaJsBWu2T8QLWoFQITk8,219
|
|
3
|
+
smooth/mcp/server.py,sha256=9SymTD4NOGTMN8P-LNGlvYNvv81yCIZfZeeuhEcAc6s,20068
|
|
4
|
+
smooth_py-0.2.0.dist-info/METADATA,sha256=o9iLsqHyEBGkOyPmW695HHmcQwV0afH2Evkp7a0O6KA,7425
|
|
5
|
+
smooth_py-0.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
6
|
+
smooth_py-0.2.0.dist-info/RECORD,,
|
smooth_py-0.1.4.dist-info/RECORD
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
smooth/__init__.py,sha256=6DXnXHhf9vINX2U7U9fpruEU4BCReAXUiE0rbBTzoMw,22222
|
|
2
|
-
smooth_py-0.1.4.dist-info/METADATA,sha256=ySqmg9gw3t1G2dr4IEZTJ0pAK_U8jIgtUiJaZ4-zcUQ,5388
|
|
3
|
-
smooth_py-0.1.4.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
4
|
-
smooth_py-0.1.4.dist-info/RECORD,,
|
|
File without changes
|