sitebay-mcp 0.1.1751179164__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.
@@ -0,0 +1,15 @@
1
+ """
2
+ SiteBay MCP Server
3
+
4
+ Provides Model Context Protocol (MCP) integration for SiteBay WordPress hosting platform.
5
+ Allows Claude Code users to manage WordPress sites, execute commands, handle staging,
6
+ backups, and more through natural language interactions.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+ __author__ = "SiteBay"
11
+ __email__ = "support@sitebay.org"
12
+
13
+ from .server import main
14
+
15
+ __all__ = ["main"]
sitebay_mcp/auth.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Authentication handling for SiteBay API
3
+ """
4
+
5
+ import os
6
+ from typing import Optional
7
+ from .exceptions import AuthenticationError, ConfigurationError
8
+
9
+
10
+ class SiteBayAuth:
11
+ """Handles SiteBay API authentication"""
12
+
13
+ def __init__(self, api_token: Optional[str] = None):
14
+ """
15
+ Initialize authentication with API token
16
+
17
+ Args:
18
+ api_token: SiteBay API token. If not provided, will try to get from environment
19
+ """
20
+ self.api_token = api_token or self._get_token_from_env()
21
+ if not self.api_token:
22
+ raise ConfigurationError(
23
+ "SiteBay API token is required. Set SITEBAY_API_TOKEN environment variable "
24
+ "or pass token directly to the server."
25
+ )
26
+
27
+ def _get_token_from_env(self) -> Optional[str]:
28
+ """Get API token from environment variables"""
29
+ return os.getenv("SITEBAY_API_TOKEN")
30
+
31
+ def get_headers(self) -> dict[str, str]:
32
+ """Get authentication headers for API requests"""
33
+ if not self.api_token:
34
+ raise AuthenticationError("No API token available")
35
+
36
+ return {
37
+ "Authorization": f"Bearer {self.api_token}",
38
+ "Content-Type": "application/json",
39
+ "Accept": "application/json",
40
+ }
41
+
42
+ def validate_token(self) -> bool:
43
+ """
44
+ Validate that the API token is properly formatted
45
+ This is a basic validation - actual verification happens on API calls
46
+ """
47
+ if not self.api_token:
48
+ return False
49
+
50
+ # Basic token format validation
51
+ if len(self.api_token) < 20: # Reasonable minimum length
52
+ return False
53
+
54
+ return True
sitebay_mcp/client.py ADDED
@@ -0,0 +1,359 @@
1
+ """
2
+ SiteBay API client for handling all API communications
3
+ """
4
+
5
+ import httpx
6
+ from typing import Any, Dict, List, Optional, Union
7
+ from .auth import SiteBayAuth
8
+ from .exceptions import APIError, AuthenticationError, SiteNotFoundError, ValidationError
9
+
10
+
11
+ class SiteBayClient:
12
+ """Client for interacting with SiteBay API"""
13
+
14
+ BASE_URL = "https://my.sitebay.org"
15
+ API_PREFIX = "/f/api/v1"
16
+
17
+ def __init__(self, auth: SiteBayAuth, timeout: float = 30.0):
18
+ """
19
+ Initialize SiteBay API client
20
+
21
+ Args:
22
+ auth: Authentication instance
23
+ timeout: Request timeout in seconds
24
+ """
25
+ self.auth = auth
26
+ self.timeout = timeout
27
+ self._client = httpx.AsyncClient(
28
+ base_url=self.BASE_URL,
29
+ timeout=timeout,
30
+ headers=self.auth.get_headers()
31
+ )
32
+
33
+ async def __aenter__(self):
34
+ """Async context manager entry"""
35
+ return self
36
+
37
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
38
+ """Async context manager exit"""
39
+ await self._client.aclose()
40
+
41
+ async def close(self):
42
+ """Close the HTTP client"""
43
+ await self._client.aclose()
44
+
45
+ def _get_url(self, endpoint: str) -> str:
46
+ """Get full URL for an API endpoint"""
47
+ if not endpoint.startswith("/"):
48
+ endpoint = "/" + endpoint
49
+ return f"{self.API_PREFIX}{endpoint}"
50
+
51
+ def _format_validation_error(self, error_data: dict) -> str:
52
+ """
53
+ Format validation error details for better readability
54
+
55
+ Args:
56
+ error_data: Error response from API
57
+
58
+ Returns:
59
+ Formatted error message with field-specific details
60
+ """
61
+ if not error_data:
62
+ return "Validation failed"
63
+
64
+ # Handle FastAPI/Pydantic validation error format
65
+ if "detail" in error_data:
66
+ detail = error_data["detail"]
67
+
68
+ # If detail is a string, return it directly
69
+ if isinstance(detail, str):
70
+ return f"Validation Error: {detail}"
71
+
72
+ # If detail is a list of validation errors
73
+ if isinstance(detail, list):
74
+ error_messages = []
75
+ for error in detail:
76
+ if isinstance(error, dict):
77
+ loc = error.get("loc", [])
78
+ msg = error.get("msg", "Invalid value")
79
+ field = " -> ".join(str(x) for x in loc) if loc else "unknown field"
80
+ error_messages.append(f"{field}: {msg}")
81
+
82
+ if error_messages:
83
+ return f"Validation Error:\n" + "\n".join(f" • {msg}" for msg in error_messages)
84
+
85
+ # Handle other error formats
86
+ if "message" in error_data:
87
+ return f"Validation Error: {error_data['message']}"
88
+
89
+ # Fallback - try to extract any useful information
90
+ if "errors" in error_data:
91
+ errors = error_data["errors"]
92
+ if isinstance(errors, dict):
93
+ error_messages = []
94
+ for field, messages in errors.items():
95
+ if isinstance(messages, list):
96
+ for msg in messages:
97
+ error_messages.append(f"{field}: {msg}")
98
+ else:
99
+ error_messages.append(f"{field}: {messages}")
100
+
101
+ if error_messages:
102
+ return f"Validation Error:\n" + "\n".join(f" • {msg}" for msg in error_messages)
103
+
104
+ return f"Validation Error: {str(error_data)}"
105
+
106
+ def _extract_field_errors(self, error_data: dict) -> dict:
107
+ """
108
+ Extract field-specific errors for programmatic access
109
+
110
+ Args:
111
+ error_data: Error response from API
112
+
113
+ Returns:
114
+ Dictionary mapping field names to error messages
115
+ """
116
+ field_errors = {}
117
+
118
+ if not error_data:
119
+ return field_errors
120
+
121
+ # Handle FastAPI/Pydantic validation error format
122
+ if "detail" in error_data and isinstance(error_data["detail"], list):
123
+ for error in error_data["detail"]:
124
+ if isinstance(error, dict):
125
+ loc = error.get("loc", [])
126
+ msg = error.get("msg", "Invalid value")
127
+ field = " -> ".join(str(x) for x in loc) if loc else "unknown"
128
+ field_errors[field] = msg
129
+
130
+ # Handle other error formats
131
+ elif "errors" in error_data and isinstance(error_data["errors"], dict):
132
+ for field, messages in error_data["errors"].items():
133
+ if isinstance(messages, list):
134
+ field_errors[field] = "; ".join(messages)
135
+ else:
136
+ field_errors[field] = str(messages)
137
+
138
+ return field_errors
139
+
140
+ async def _request(
141
+ self,
142
+ method: str,
143
+ endpoint: str,
144
+ params: Optional[Dict[str, Any]] = None,
145
+ json_data: Optional[Dict[str, Any]] = None,
146
+ **kwargs
147
+ ) -> Any:
148
+ """
149
+ Make an HTTP request to the SiteBay API
150
+
151
+ Args:
152
+ method: HTTP method (GET, POST, PATCH, DELETE)
153
+ endpoint: API endpoint path
154
+ params: Query parameters
155
+ json_data: JSON body data
156
+ **kwargs: Additional httpx request arguments
157
+
158
+ Returns:
159
+ Response data (parsed JSON or raw response)
160
+
161
+ Raises:
162
+ APIError: For API errors
163
+ AuthenticationError: For authentication failures
164
+ """
165
+ url = self._get_url(endpoint)
166
+
167
+ try:
168
+ response = await self._client.request(
169
+ method=method,
170
+ url=url,
171
+ params=params,
172
+ json=json_data,
173
+ **kwargs
174
+ )
175
+
176
+ # Handle different response codes
177
+ if response.status_code == 401:
178
+ raise AuthenticationError("Invalid or expired API token")
179
+ elif response.status_code == 404:
180
+ raise SiteNotFoundError("Requested resource not found")
181
+ elif response.status_code == 422:
182
+ # Handle validation errors with detailed information
183
+ try:
184
+ error_data = response.json()
185
+ error_message = self._format_validation_error(error_data)
186
+ field_errors = self._extract_field_errors(error_data)
187
+ except Exception:
188
+ error_message = f"Validation Error: {response.text}"
189
+ error_data = None
190
+ field_errors = {}
191
+
192
+ raise ValidationError(
193
+ message=error_message,
194
+ field_errors=field_errors
195
+ )
196
+ elif response.status_code >= 400:
197
+ try:
198
+ error_data = response.json()
199
+ error_message = error_data.get("detail", f"API Error: {response.status_code}")
200
+ except Exception:
201
+ error_message = f"API Error: {response.status_code} - {response.text}"
202
+
203
+ raise APIError(
204
+ message=error_message,
205
+ status_code=response.status_code,
206
+ response_data=error_data if 'error_data' in locals() else None
207
+ )
208
+
209
+ # Try to parse JSON response
210
+ try:
211
+ return response.json()
212
+ except Exception:
213
+ # Return raw response if not JSON
214
+ return response.text
215
+
216
+ except httpx.RequestError as e:
217
+ raise APIError(f"Network error: {str(e)}")
218
+
219
+ async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
220
+ """Make GET request"""
221
+ return await self._request("GET", endpoint, params=params)
222
+
223
+ async def post(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Any:
224
+ """Make POST request"""
225
+ return await self._request("POST", endpoint, params=params, json_data=json_data)
226
+
227
+ async def patch(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Any:
228
+ """Make PATCH request"""
229
+ return await self._request("PATCH", endpoint, params=params, json_data=json_data)
230
+
231
+ async def delete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
232
+ """Make DELETE request"""
233
+ return await self._request("DELETE", endpoint, params=params)
234
+
235
+ # Site Management Methods
236
+ async def list_sites(self, team_id: Optional[str] = None) -> List[Dict[str, Any]]:
237
+ """List all sites for the user"""
238
+ params = {"team_id": team_id} if team_id else None
239
+ response = await self.get("/site", params=params)
240
+ return response.get("results", [])
241
+
242
+ async def get_site(self, fqdn: str) -> Dict[str, Any]:
243
+ """Get details for a specific site"""
244
+ return await self.get(f"/site/{fqdn}")
245
+
246
+ async def create_site(self, site_data: Dict[str, Any]) -> Dict[str, Any]:
247
+ """Create a new site"""
248
+ return await self.post("/site", json_data=site_data)
249
+
250
+ async def update_site(self, fqdn: str, site_data: Dict[str, Any]) -> Dict[str, Any]:
251
+ """Update an existing site"""
252
+ return await self.patch(f"/site/{fqdn}", json_data=site_data)
253
+
254
+ async def delete_site(self, fqdn: str) -> Dict[str, Any]:
255
+ """Delete a site"""
256
+ return await self.delete(f"/site/{fqdn}")
257
+
258
+ # Site Operations Methods
259
+ async def execute_shell_command(self, fqdn: str, command: str) -> Any:
260
+ """Execute a shell command on a site"""
261
+ return await self.post(f"/site/{fqdn}/cmd", json_data={"cmd": command})
262
+
263
+ async def edit_file(self, fqdn: str, file_path: str, content: str) -> str:
264
+ """Edit a file in the site's wp-content directory"""
265
+ return await self.post(
266
+ f"/site/{fqdn}/wpfile_diff_edit",
267
+ json_data={"path": file_path, "content": content}
268
+ )
269
+
270
+ async def get_site_events(self, fqdn: str, after_datetime: Optional[str] = None) -> List[Dict[str, Any]]:
271
+ """Get site events"""
272
+ params = {"after_datetime": after_datetime} if after_datetime else None
273
+ response = await self.get(f"/site/{fqdn}/event", params=params)
274
+ return response.get("results", [])
275
+
276
+ # Staging Methods
277
+ async def create_staging_site(self, fqdn: str, staging_data: Dict[str, Any]) -> Dict[str, Any]:
278
+ """Create a staging site"""
279
+ return await self.post(f"/site/{fqdn}/stage", json_data=staging_data)
280
+
281
+ async def delete_staging_site(self, fqdn: str) -> Dict[str, Any]:
282
+ """Delete a staging site"""
283
+ return await self.delete(f"/site/{fqdn}/stage")
284
+
285
+ async def commit_staging_site(self, fqdn: str) -> Dict[str, Any]:
286
+ """Commit staging site to live"""
287
+ return await self.post(f"/site/{fqdn}/stage/commit")
288
+
289
+ # Backup/Restore Methods
290
+ async def get_backup_commits(self, fqdn: str, number_to_fetch: int = 10) -> List[Dict[str, Any]]:
291
+ """Get backup commits for a site"""
292
+ params = {"number_to_fetch": number_to_fetch}
293
+ return await self.get(f"/site/{fqdn}/pit_restore/commits", params=params)
294
+
295
+ async def create_restore(self, fqdn: str, restore_data: Dict[str, Any]) -> Dict[str, Any]:
296
+ """Create a point-in-time restore"""
297
+ return await self.post(f"/site/{fqdn}/pit_restore", json_data=restore_data)
298
+
299
+ async def list_restores(self, fqdn: str) -> List[Dict[str, Any]]:
300
+ """List all restores for a site"""
301
+ response = await self.get(f"/site/{fqdn}/pit_restore")
302
+ return response.get("results", [])
303
+
304
+ # External Path Methods
305
+ async def list_external_paths(self, fqdn: str) -> List[Dict[str, Any]]:
306
+ """List external paths for a site"""
307
+ response = await self.get(f"/site/{fqdn}/external_path")
308
+ return response.get("results", [])
309
+
310
+ async def create_external_path(self, fqdn: str, path_data: Dict[str, Any]) -> Dict[str, Any]:
311
+ """Create an external path"""
312
+ return await self.post(f"/site/{fqdn}/external_path", json_data=path_data)
313
+
314
+ async def update_external_path(self, fqdn: str, path_id: str, path_data: Dict[str, Any]) -> Dict[str, Any]:
315
+ """Update an external path"""
316
+ return await self.patch(f"/site/{fqdn}/external_path/{path_id}", json_data=path_data)
317
+
318
+ async def delete_external_path(self, fqdn: str, path_id: str) -> Dict[str, Any]:
319
+ """Delete an external path"""
320
+ return await self.delete(f"/site/{fqdn}/external_path/{path_id}")
321
+
322
+ # Proxy Methods
323
+ async def wordpress_proxy(self, proxy_data: Dict[str, Any]) -> Any:
324
+ """Proxy request to WordPress API"""
325
+ return await self.post("/wp-proxy", json_data=proxy_data)
326
+
327
+ async def shopify_proxy(self, proxy_data: Dict[str, Any]) -> Any:
328
+ """Proxy request to Shopify API"""
329
+ return await self.post("/shopify-proxy", json_data=proxy_data)
330
+
331
+ async def posthog_proxy(self, proxy_data: Dict[str, Any]) -> Any:
332
+ """Proxy request to PostHog API"""
333
+ return await self.post("/posthog-proxy", json_data=proxy_data)
334
+
335
+ # Team Methods
336
+ async def list_teams(self) -> List[Dict[str, Any]]:
337
+ """List user teams"""
338
+ response = await self.get("/team")
339
+ return response.get("results", [])
340
+
341
+ # Template and Region Methods
342
+ async def list_templates(self) -> List[Dict[str, Any]]:
343
+ """List available templates"""
344
+ response = await self.get("/template")
345
+ return response.get("results", [])
346
+
347
+ async def list_regions(self) -> List[Dict[str, Any]]:
348
+ """List available regions"""
349
+ return await self.get("/region")
350
+
351
+ # Account Methods
352
+ async def get_affiliate_referrals(self) -> List[Dict[str, Any]]:
353
+ """Get affiliate referrals"""
354
+ response = await self.get("/account/referred_user")
355
+ return response.get("results", [])
356
+
357
+ async def create_checkout_session(self, checkout_data: Dict[str, Any]) -> Dict[str, Any]:
358
+ """Create Stripe checkout session"""
359
+ return await self.post("/create_checkout_session", json_data=checkout_data)
@@ -0,0 +1,45 @@
1
+ """
2
+ Custom exceptions for SiteBay MCP Server
3
+ """
4
+
5
+
6
+ class SiteBayError(Exception):
7
+ """Base exception for SiteBay MCP operations"""
8
+ pass
9
+
10
+
11
+ class AuthenticationError(SiteBayError):
12
+ """Raised when authentication fails"""
13
+ pass
14
+
15
+
16
+ class APIError(SiteBayError):
17
+ """Raised when SiteBay API returns an error"""
18
+
19
+ def __init__(self, message: str, status_code: int = None, response_data: dict = None):
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+ self.response_data = response_data
23
+
24
+
25
+ class ValidationError(SiteBayError):
26
+ """Raised when request validation fails"""
27
+
28
+ def __init__(self, message: str, field_errors: dict = None):
29
+ super().__init__(message)
30
+ self.field_errors = field_errors or {}
31
+
32
+
33
+ class SiteNotFoundError(SiteBayError):
34
+ """Raised when requested site is not found"""
35
+ pass
36
+
37
+
38
+ class TeamNotFoundError(SiteBayError):
39
+ """Raised when requested team is not found"""
40
+ pass
41
+
42
+
43
+ class ConfigurationError(SiteBayError):
44
+ """Raised when configuration is invalid"""
45
+ pass
@@ -0,0 +1,171 @@
1
+ """
2
+ SiteBay MCP Resources
3
+
4
+ Provides readable resources for site configurations, logs, and metrics.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Dict, List
9
+ from fastmcp.server import Context
10
+ from .client import SiteBayClient
11
+ from .exceptions import SiteBayError
12
+
13
+
14
+ async def get_site_config_resource(ctx: Context, site_fqdn: str) -> str:
15
+ """
16
+ Get site configuration as a resource.
17
+
18
+ Args:
19
+ ctx: FastMCP context
20
+ site_fqdn: Site domain name
21
+
22
+ Returns:
23
+ JSON formatted site configuration
24
+ """
25
+ try:
26
+ ctx.logger.info(f"Fetching configuration resource for: {site_fqdn}")
27
+
28
+ from .server import initialize_client
29
+ client = await initialize_client()
30
+
31
+ site = await client.get_site(site_fqdn)
32
+
33
+ # Format as readable configuration
34
+ config = {
35
+ "site_info": {
36
+ "domain": site.get("fqdn"),
37
+ "title": site.get("wp_title"),
38
+ "status": site.get("status"),
39
+ "region": site.get("region_name"),
40
+ "created": site.get("created_at"),
41
+ "updated": site.get("updated_at")
42
+ },
43
+ "technical_specs": {
44
+ "php_version": site.get("php_version"),
45
+ "mysql_version": site.get("mysql_version"),
46
+ "wordpress_version": site.get("wp_version"),
47
+ "git_enabled": site.get("git_enabled", False)
48
+ },
49
+ "urls": {
50
+ "site_url": site.get("site_url"),
51
+ "admin_url": site.get("admin_url")
52
+ },
53
+ "features": {
54
+ "staging_available": bool(site.get("staging_site")),
55
+ "git_integration": site.get("git_enabled", False),
56
+ "backup_enabled": True # SiteBay always has backups
57
+ }
58
+ }
59
+
60
+ return json.dumps(config, indent=2)
61
+
62
+ except SiteBayError as e:
63
+ ctx.logger.error(f"Error fetching config for {site_fqdn}: {str(e)}")
64
+ return f"Error: {str(e)}"
65
+
66
+
67
+ async def get_site_events_resource(ctx: Context, site_fqdn: str, limit: int = 50) -> str:
68
+ """
69
+ Get site events/logs as a resource.
70
+
71
+ Args:
72
+ ctx: FastMCP context
73
+ site_fqdn: Site domain name
74
+ limit: Maximum number of events to fetch
75
+
76
+ Returns:
77
+ JSON formatted site events
78
+ """
79
+ try:
80
+ ctx.logger.info(f"Fetching events resource for: {site_fqdn}")
81
+
82
+ from .server import initialize_client
83
+ client = await initialize_client()
84
+
85
+ events = await client.get_site_events(site_fqdn)
86
+
87
+ # Limit and format events
88
+ limited_events = events[:limit]
89
+
90
+ formatted_events = {
91
+ "site": site_fqdn,
92
+ "total_events": len(events),
93
+ "showing": len(limited_events),
94
+ "events": [
95
+ {
96
+ "timestamp": event.get("created_at"),
97
+ "type": event.get("event_type"),
98
+ "status": event.get("status"),
99
+ "description": event.get("description"),
100
+ "metadata": event.get("metadata", {})
101
+ }
102
+ for event in limited_events
103
+ ]
104
+ }
105
+
106
+ return json.dumps(formatted_events, indent=2)
107
+
108
+ except SiteBayError as e:
109
+ ctx.logger.error(f"Error fetching events for {site_fqdn}: {str(e)}")
110
+ return f"Error: {str(e)}"
111
+
112
+
113
+ async def get_account_summary_resource(ctx: Context) -> str:
114
+ """
115
+ Get account summary as a resource.
116
+
117
+ Args:
118
+ ctx: FastMCP context
119
+
120
+ Returns:
121
+ JSON formatted account summary
122
+ """
123
+ try:
124
+ ctx.logger.info("Fetching account summary resource")
125
+
126
+ from .server import initialize_client
127
+ client = await initialize_client()
128
+
129
+ # Get sites and teams in parallel
130
+ sites = await client.list_sites()
131
+ teams = await client.list_teams()
132
+ regions = await client.list_regions()
133
+ templates = await client.list_templates()
134
+
135
+ summary = {
136
+ "account_overview": {
137
+ "total_sites": len(sites),
138
+ "total_teams": len(teams),
139
+ "available_regions": len(regions),
140
+ "available_templates": len(templates)
141
+ },
142
+ "sites_by_status": {},
143
+ "sites_by_region": {},
144
+ "recent_sites": []
145
+ }
146
+
147
+ # Analyze sites
148
+ for site in sites:
149
+ status = site.get("status", "unknown")
150
+ summary["sites_by_status"][status] = summary["sites_by_status"].get(status, 0) + 1
151
+
152
+ region = site.get("region_name", "unknown")
153
+ summary["sites_by_region"][region] = summary["sites_by_region"].get(region, 0) + 1
154
+
155
+ # Get 5 most recent sites
156
+ sorted_sites = sorted(sites, key=lambda x: x.get("created_at", ""), reverse=True)
157
+ summary["recent_sites"] = [
158
+ {
159
+ "domain": site.get("fqdn"),
160
+ "status": site.get("status"),
161
+ "created": site.get("created_at"),
162
+ "region": site.get("region_name")
163
+ }
164
+ for site in sorted_sites[:5]
165
+ ]
166
+
167
+ return json.dumps(summary, indent=2)
168
+
169
+ except SiteBayError as e:
170
+ ctx.logger.error(f"Error fetching account summary: {str(e)}")
171
+ return f"Error: {str(e)}"