smooth-py 0.3.5.dev20251107__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/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