smooth-py 0.1.3.post0__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 CHANGED
@@ -4,11 +4,8 @@ import asyncio
4
4
  import logging
5
5
  import os
6
6
  import time
7
- from typing import (
8
- Any,
9
- Literal,
10
- )
11
- from urllib.parse import urlencode
7
+ import urllib.parse
8
+ from typing import Any, Literal, Type
12
9
 
13
10
  import httpx
14
11
  import requests
@@ -20,6 +17,17 @@ logger = logging.getLogger("smooth")
20
17
 
21
18
  BASE_URL = "https://api2.circlemind.co/api/"
22
19
 
20
+
21
+ # --- Utils ---
22
+
23
+
24
+ def _encode_url(url: str, interactive: bool = True, embed: bool = False) -> str:
25
+ parsed_url = urllib.parse.urlparse(url)
26
+ params = urllib.parse.parse_qs(parsed_url.query)
27
+ params.update({"interactive": "true" if interactive else "false", "embed": "true" if embed else "false"})
28
+ return urllib.parse.urlunparse(parsed_url._replace(query=urllib.parse.urlencode(params)))
29
+
30
+
23
31
  # --- Models ---
24
32
  # These models define the data structures for API requests and responses.
25
33
 
@@ -40,6 +48,9 @@ class TaskRequest(BaseModel):
40
48
  """Run task request model."""
41
49
 
42
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
+ )
43
54
  agent: Literal["smooth"] = Field(default="smooth", description="The agent to use for the task.")
44
55
  max_steps: int = Field(default=32, ge=2, le=128, description="Maximum number of steps the agent can take (min 2, max 128).")
45
56
  device: Literal["desktop", "mobile"] = Field(default="mobile", description="Device type for the task. Default is mobile.")
@@ -66,13 +77,14 @@ class BrowserSessionRequest(BaseModel):
66
77
  default=None,
67
78
  description=("The session ID to open in the browser. If None, a new session will be created with a random name."),
68
79
  )
80
+ live_view: bool | None = Field(default=True, description="Request a live URL to interact with the browser session.")
69
81
 
70
82
 
71
83
  class BrowserSessionResponse(BaseModel):
72
84
  """Browser session response model."""
73
85
 
74
- live_url: str = Field(description="The live URL to interact with the browser session.")
75
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.")
76
88
 
77
89
 
78
90
  class BrowserSessionsResponse(BaseModel):
@@ -151,6 +163,22 @@ class BaseClient:
151
163
  # --- Synchronous Client ---
152
164
 
153
165
 
166
+ class BrowserSessionHandle(BaseModel):
167
+ """Browser session handle model."""
168
+
169
+ browser_session: BrowserSessionResponse = Field(description="The browser session associated with this handle.")
170
+
171
+ def session_id(self):
172
+ """Returns the session ID for the browser session."""
173
+ return self.browser_session.session_id
174
+
175
+ def live_url(self, interactive: bool = True, embed: bool = False):
176
+ """Returns the live URL for the browser session."""
177
+ if self.browser_session.live_url:
178
+ return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
179
+ return None
180
+
181
+
154
182
  class TaskHandle:
155
183
  """A handle to a running task."""
156
184
 
@@ -182,20 +210,15 @@ class TaskHandle:
182
210
 
183
211
  def live_url(self, interactive: bool = True, embed: bool = False, timeout: int | None = None):
184
212
  """Returns the live URL for the task."""
185
- params = {
186
- "interactive": interactive,
187
- "embed": embed
188
- }
189
-
190
213
  if self._task_response and self._task_response.live_url:
191
- return f"{self._task_response.live_url}?{urlencode(params)}"
214
+ return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
192
215
 
193
216
  start_time = time.time()
194
217
  while timeout is None or (time.time() - start_time) < timeout:
195
218
  task_response = self._client._get_task(self.id) # pyright: ignore [reportPrivateUsage]
196
219
  self._task_response = task_response
197
220
  if self._task_response.live_url:
198
- return f"{self._task_response.live_url}?{urlencode(params)}"
221
+ return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
199
222
  time.sleep(1)
200
223
 
201
224
  raise TimeoutError(f"Live URL not available for task {self.id}.")
@@ -263,6 +286,7 @@ class SmoothClient(BaseClient):
263
286
  def run(
264
287
  self,
265
288
  task: str,
289
+ response_model: dict[str, Any] | Type[BaseModel] | None = None,
266
290
  agent: Literal["smooth"] = "smooth",
267
291
  max_steps: int = 32,
268
292
  device: Literal["desktop", "mobile"] = "mobile",
@@ -280,6 +304,7 @@ class SmoothClient(BaseClient):
280
304
 
281
305
  Args:
282
306
  task: The task to run.
307
+ response_model: If provided, the schema describing the desired output structure.
283
308
  agent: The agent to use for the task.
284
309
  max_steps: Maximum number of steps the agent can take (max 64).
285
310
  device: Device type for the task. Default is mobile.
@@ -298,6 +323,7 @@ class SmoothClient(BaseClient):
298
323
  """
299
324
  payload = TaskRequest(
300
325
  task=task,
326
+ response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
301
327
  agent=agent,
302
328
  max_steps=max_steps,
303
329
  device=device,
@@ -312,11 +338,12 @@ class SmoothClient(BaseClient):
312
338
 
313
339
  return TaskHandle(initial_response.id, self)
314
340
 
315
- def open_session(self, session_id: str | None = None) -> BrowserSessionResponse:
341
+ def open_session(self, session_id: str | None = None, live_view: bool = True) -> BrowserSessionHandle:
316
342
  """Gets an interactive browser instance.
317
343
 
318
344
  Args:
319
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.
320
347
 
321
348
  Returns:
322
349
  The browser session details, including the live URL.
@@ -327,10 +354,10 @@ class SmoothClient(BaseClient):
327
354
  try:
328
355
  response = self._session.post(
329
356
  f"{self.base_url}/browser/session",
330
- 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),
331
358
  )
332
359
  data = self._handle_response(response)
333
- return BrowserSessionResponse(**data["r"])
360
+ return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
334
361
  except requests.exceptions.RequestException as e:
335
362
  logger.error(f"Request failed: {e}")
336
363
  raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
@@ -396,19 +423,15 @@ class AsyncTaskHandle:
396
423
 
397
424
  async def live_url(self, interactive: bool = True, embed: bool = False, timeout: int | None = None):
398
425
  """Returns the live URL for the task."""
399
- params = {
400
- "interactive": interactive,
401
- "embed": embed
402
- }
403
426
  if self._task_response and self._task_response.live_url:
404
- return f"{self._task_response.live_url}?{urlencode(params)}"
427
+ return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
405
428
 
406
429
  start_time = time.time()
407
430
  while timeout is None or (time.time() - start_time) < timeout:
408
431
  task_response = await self._client._get_task(self.id) # pyright: ignore [reportPrivateUsage]
409
432
  self._task_response = task_response
410
433
  if task_response.live_url is not None:
411
- return f"{task_response.live_url}?{urlencode(params)}"
434
+ return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
412
435
  await asyncio.sleep(1)
413
436
 
414
437
  raise TimeoutError(f"Live URL not available for task {self.id}.")
@@ -428,6 +451,7 @@ class AsyncTaskHandle:
428
451
 
429
452
  raise TimeoutError(f"Recording URL not available for task {self.id}.")
430
453
 
454
+
431
455
  class SmoothAsyncClient(BaseClient):
432
456
  """An asynchronous client for the API."""
433
457
 
@@ -470,6 +494,7 @@ class SmoothAsyncClient(BaseClient):
470
494
  async def run(
471
495
  self,
472
496
  task: str,
497
+ response_model: dict[str, Any] | Type[BaseModel] | None = None,
473
498
  agent: Literal["smooth"] = "smooth",
474
499
  max_steps: int = 32,
475
500
  device: Literal["desktop", "mobile"] = "mobile",
@@ -487,6 +512,7 @@ class SmoothAsyncClient(BaseClient):
487
512
 
488
513
  Args:
489
514
  task: The task to run.
515
+ response_model: If provided, the schema describing the desired output structure.
490
516
  agent: The agent to use for the task.
491
517
  max_steps: Maximum number of steps the agent can take (max 64).
492
518
  device: Device type for the task. Default is mobile.
@@ -507,6 +533,7 @@ class SmoothAsyncClient(BaseClient):
507
533
  """
508
534
  payload = TaskRequest(
509
535
  task=task,
536
+ response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
510
537
  agent=agent,
511
538
  max_steps=max_steps,
512
539
  device=device,
@@ -521,11 +548,12 @@ class SmoothAsyncClient(BaseClient):
521
548
  initial_response = await self._submit_task(payload)
522
549
  return AsyncTaskHandle(initial_response.id, self)
523
550
 
524
- async def open_session(self, session_id: str | None = None) -> BrowserSessionResponse:
551
+ async def open_session(self, session_id: str | None = None, live_view: bool = True) -> BrowserSessionHandle:
525
552
  """Opens an interactive browser instance asynchronously.
526
553
 
527
554
  Args:
528
555
  session_id: The session ID to associate with the browser.
556
+ live_view: Whether to enable live view for the session.
529
557
 
530
558
  Returns:
531
559
  The browser session details, including the live URL.
@@ -536,10 +564,10 @@ class SmoothAsyncClient(BaseClient):
536
564
  try:
537
565
  response = await self._client.post(
538
566
  f"{self.base_url}/browser/session",
539
- 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),
540
568
  )
541
569
  data = self._handle_response(response)
542
- return BrowserSessionResponse(**data["r"])
570
+ return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
543
571
  except httpx.RequestError as e:
544
572
  logger.error(f"Request failed: {e}")
545
573
  raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
@@ -573,3 +601,20 @@ class SmoothAsyncClient(BaseClient):
573
601
  async def close(self):
574
602
  """Closes the async client session."""
575
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
@@ -0,0 +1,9 @@
1
+ """Smooth SDK MCP Integration.
2
+
3
+ This module provides Model Context Protocol integration for the Smooth SDK,
4
+ allowing browser automation through AI assistants.
5
+ """
6
+
7
+ from .server import SmoothMCP
8
+
9
+ __all__ = ["SmoothMCP"]
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.1.3.post0
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,,
@@ -1,4 +0,0 @@
1
- smooth/__init__.py,sha256=jIEM1usuwrZWq-m1YgsUsLCkvUkhS9BNqgFc3HUPEvw,21263
2
- smooth_py-0.1.3.post0.dist-info/METADATA,sha256=x4u365t5CvrRvMPsrCrLy2sVNT4ojf8nK8pdntodEdw,5394
3
- smooth_py-0.1.3.post0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
4
- smooth_py-0.1.3.post0.dist-info/RECORD,,