py-mcpdock-cli 1.0.13__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,43 @@
1
+ """
2
+ WebSocket Runner implementation for remote MCP servers
3
+ """
4
+ import json
5
+ import asyncio
6
+ import signal
7
+ import sys
8
+ from typing import Dict, Any, Optional
9
+
10
+ from rich import print as rprint
11
+
12
+ from ..utils.logger import verbose
13
+
14
+
15
+ async def create_ws_runner(
16
+ deployment_url: str,
17
+ config: Dict[str, Any],
18
+ api_key: Optional[str] = None
19
+ ) -> None:
20
+ """
21
+ Creates a WebSocket runner for connecting to a remote server.
22
+ This is a placeholder function that will be connected to a real implementation.
23
+ """
24
+ verbose(f"Starting WebSocket runner: {deployment_url}")
25
+ rprint(f"[green]WebSocket connection successful: {deployment_url}[/green]")
26
+ rprint(f"Configuration: {json.dumps(config, indent=2)}")
27
+ rprint("Press Ctrl+C to stop the connection")
28
+
29
+ # Setup signal handling for graceful shutdown
30
+ def handle_sigint(sig, frame):
31
+ rprint("[yellow]Received stop signal, closing connection...[/yellow]")
32
+ sys.exit(0)
33
+
34
+ signal.signal(signal.SIGINT, handle_sigint)
35
+
36
+ # Keep the process running (equivalent to the Promise in JS)
37
+ try:
38
+ # This is a simple placeholder that keeps the process running
39
+ # In a real implementation, this would be replaced with actual connection logic
40
+ while True:
41
+ await asyncio.sleep(1)
42
+ except asyncio.CancelledError:
43
+ rprint("[yellow]WebSocket connection cancelled.[/yellow]")
cli/types/__init__.py ADDED
File without changes
cli/types/registry.py ADDED
@@ -0,0 +1,69 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional, Dict, Any
3
+
4
+ # Using dataclasses for structured type definitions
5
+
6
+
7
+ @dataclass
8
+ class ConfigSchemaProperty:
9
+ # Represents a single property within the configSchema
10
+ # Using Any for now, can be refined if specific property types are known
11
+ type: Optional[str] = None
12
+ description: Optional[str] = None
13
+ default: Any = None
14
+ # Add other potential schema attributes if needed
15
+
16
+
17
+ @dataclass
18
+ class ConfigSchema:
19
+ env: Dict[str, ConfigSchemaProperty] = field(default_factory=dict)
20
+ required: List[str] = field(default_factory=list)
21
+ args: Optional[List[str]] = None
22
+
23
+
24
+ @dataclass
25
+ class ConnectionDetails:
26
+ type: str
27
+ stdioFunction: Optional[List[str]] = None
28
+ deploymentUrl: Optional[str] = None
29
+ published: Optional[bool] = None
30
+ configSchema: Optional[ConfigSchema] = None # Use the nested dataclass
31
+
32
+
33
+ @dataclass
34
+ class ConfiguredServer:
35
+ command: str
36
+ args: List[str]
37
+ env: Optional[Dict[str, str]] = None
38
+
39
+
40
+ @dataclass
41
+ class MCPConfig:
42
+ # Represents the overall configuration structure potentially saved
43
+ mcpServers: Dict[str, ConfiguredServer] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class ServerConfig:
48
+ # Represents the configuration *for* a specific server instance
49
+ # Using a general dict for extra keys corresponding to `[key: string]: unknown`
50
+ qualifiedName: str
51
+ connections: List[ConnectionDetails]
52
+ remote: Optional[bool] = None
53
+ additional_config: Dict[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass
57
+ class ClientConfig:
58
+ # Represents the client-side configuration (e.g., installed servers)
59
+ # Using Dict[str, Any] for flexibility as the TS type was Record<string, unknown>
60
+ mcpServers: Dict[str, Any] = field(default_factory=dict)
61
+
62
+
63
+ @dataclass
64
+ class RegistryServer:
65
+ # Represents a server as listed in a registry
66
+ qualifiedName: str
67
+ connections: List[ConnectionDetails]
68
+ displayName: Optional[str] = None
69
+ remote: Optional[bool] = None
cli/utils/__init__.py ADDED
File without changes
cli/utils/client.py ADDED
File without changes
cli/utils/config.py ADDED
@@ -0,0 +1,441 @@
1
+ import json
2
+ import questionary
3
+ from rich import print as rprint
4
+ from typing import Any, Dict, List, Optional, Set, Tuple, TypeVar
5
+
6
+ # Import proper types from registry module
7
+ from ..types.registry import (
8
+ ConnectionDetails, RegistryServer,
9
+ ServerConfig, ConfiguredServer, ConfigSchemaProperty
10
+ )
11
+
12
+
13
+ def convert_value_to_type(value: Any, type_str: Optional[str]) -> Any:
14
+ """Converts a value to the specified type string."""
15
+ if not type_str:
16
+ return value
17
+
18
+ def invalid(expected: str):
19
+ raise ValueError(f"Invalid {expected} value: {json.dumps(value)}")
20
+
21
+ try:
22
+ if type_str == "boolean":
23
+ str_val = str(value).lower()
24
+ if str_val == "true":
25
+ return True
26
+ if str_val == "false":
27
+ return False
28
+ invalid("boolean")
29
+ elif type_str == "number":
30
+ return float(value)
31
+ elif type_str == "integer":
32
+ return int(value)
33
+ elif type_str == "string":
34
+ return str(value)
35
+ elif type_str == "array":
36
+ if isinstance(value, list):
37
+ return value
38
+ return [v.strip() for v in str(value).split(",")]
39
+ else:
40
+ return value
41
+ except (ValueError, TypeError):
42
+ invalid(type_str)
43
+
44
+
45
+ def format_config_values(
46
+ connection: ConnectionDetails,
47
+ config_values: Optional[Dict[str, Any]] = None,
48
+ ) -> Dict[str, Any]:
49
+ """Formats and validates config values based on the connection's schema."""
50
+ formatted_values: Dict[str, Any] = {}
51
+ missing_required: List[str] = []
52
+ config_values = config_values or {}
53
+
54
+ schema = connection.configSchema or {}
55
+ env = schema.env if schema else {}
56
+ if not env:
57
+ return config_values
58
+
59
+ required: Set[str] = set(schema.required if schema else [])
60
+
61
+ for property_name, prop_details in env.items():
62
+ value = config_values.get(property_name)
63
+ prop_type = prop_details.type
64
+
65
+ if value is None:
66
+ if property_name in required:
67
+ missing_required.append(property_name)
68
+ else:
69
+ formatted_values[property_name] = prop_details.default
70
+ else:
71
+ try:
72
+ formatted_values[property_name] = convert_value_to_type(value, prop_type)
73
+ except ValueError as e:
74
+ # If conversion fails but it's not required, set to default or None
75
+ if property_name not in required:
76
+ formatted_values[property_name] = prop_details.default
77
+ else:
78
+ # Re-raise or handle error if conversion fails for a required field
79
+ # For now, let's add to missing required to indicate an issue
80
+ print(f"Warning: Could not convert required value for {property_name}: {e}")
81
+ missing_required.append(f"{property_name} (invalid format)")
82
+
83
+ if missing_required:
84
+ raise ValueError(
85
+ f"Missing or invalid required config values: {', '.join(missing_required)}"
86
+ )
87
+
88
+ return formatted_values
89
+
90
+
91
+ def validate_config(
92
+ connection: ConnectionDetails,
93
+ saved_config: Optional[Dict[str, Any]] = None,
94
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
95
+ """Validates saved config against the connection schema."""
96
+ schema = connection.configSchema or {}
97
+ env = schema.env if schema else {}
98
+ if not env:
99
+ return True, {}
100
+
101
+ saved_config = saved_config or {}
102
+
103
+ try:
104
+ formatted_config = format_config_values(connection, saved_config)
105
+ required = set(schema.required if schema else [])
106
+ has_all_required = all(
107
+ key in formatted_config and formatted_config[key] is not None
108
+ for key in required
109
+ )
110
+ return has_all_required, formatted_config
111
+ except ValueError:
112
+ # Attempt to return partially valid config if formatting fails
113
+ try:
114
+ partial_config = {
115
+ k: v for k, v in saved_config.items() if v is not None
116
+ }
117
+ # Check if the partial config *still* satisfies required fields *that are present*
118
+ # This is tricky, maybe just return False and the original partial config
119
+ return False, partial_config
120
+ except Exception:
121
+ return False, None
122
+
123
+
124
+ async def prompt_for_config_value(
125
+ key: str,
126
+ schema_prop: ConfigSchemaProperty,
127
+ required: Set[str],
128
+ ) -> Any:
129
+ """Prompts the user for a single config value based on schema property."""
130
+ is_required = key in required
131
+ required_text = " [red](required)[/red]" if is_required else " (optional)"
132
+ message = f"{schema_prop.description or f'Enter value for {key}'}{required_text}"
133
+ prop_type = schema_prop.type
134
+ default_value = schema_prop.default
135
+
136
+ # Determine questionary prompt type
137
+ if key.lower().endswith("key") or key.lower().endswith("secret") or key.lower().endswith("token"):
138
+ prompt_method = questionary.password
139
+ elif prop_type == "boolean":
140
+ prompt_method = questionary.confirm
141
+ # questionary confirm default needs bool or None
142
+ if default_value is not None:
143
+ default_value = str(default_value).lower() == 'true'
144
+ else:
145
+ default_value = None # Or False if you prefer
146
+ elif prop_type == "number" or prop_type == "integer":
147
+ # Using text prompt with validation for numbers
148
+ prompt_method = questionary.text
149
+ elif prop_type == "array":
150
+ message += " (comma-separated)"
151
+ prompt_method = questionary.text
152
+ else:
153
+ prompt_method = questionary.text
154
+
155
+ # Validation function
156
+ def validate_input(text: str) -> bool | str:
157
+ if is_required and not text:
158
+ return "This field is required."
159
+ if prop_type == "number" or prop_type == "integer":
160
+ try:
161
+ if prop_type == "integer":
162
+ int(text)
163
+ else:
164
+ float(text)
165
+ return True
166
+ except ValueError:
167
+ return "Please enter a valid number."
168
+ return True
169
+
170
+ # Use appropriate prompt method
171
+ if prompt_method == questionary.confirm:
172
+ # Handle confirm separately as it doesn't take validate
173
+ # Note: questionary.confirm returns True/False directly
174
+ # We need to handle the case where the user might skip an optional confirm
175
+ # For simplicity, let's assume confirm always gets an answer if prompted
176
+ answer = await prompt_method(message, default=default_value).ask_async()
177
+ # If it's required and they somehow skip (e.g., Ctrl+C), handle appropriately
178
+ if is_required and answer is None:
179
+ rprint("[bold red]Required field cannot be skipped.[/bold red]")
180
+ # Re-prompt or exit - for now, return None which might fail later validation
181
+ return None
182
+ return answer
183
+ else:
184
+ # For text, password
185
+ q = prompt_method(
186
+ message,
187
+ default=str(default_value) if default_value is not None else "",
188
+ validate=validate_input
189
+ )
190
+ result = await q.ask_async()
191
+ if result is None and is_required:
192
+ rprint("[bold red]Required field cannot be skipped.[/bold red]")
193
+ return None # Or raise an error / re-prompt
194
+ # Convert back if needed (e.g., for numbers, arrays)
195
+ if result is not None:
196
+ try:
197
+ return convert_value_to_type(result, prop_type)
198
+ except ValueError:
199
+ rprint(
200
+ f"[yellow]Warning: Could not convert input '{result}' to type '{prop_type}'. Using raw input.[/yellow]")
201
+ return result # Return raw input if conversion fails after prompt
202
+ return result # Return None if skipped (and not required)
203
+
204
+
205
+ async def collect_config_values(
206
+ connection: ConnectionDetails,
207
+ existing_values: Optional[Dict[str, Any]] = None,
208
+ ) -> Dict[str, Any]:
209
+ """Collects config values from existing values or user prompts."""
210
+ schema = connection.configSchema or {}
211
+ env = schema.env if schema else {}
212
+ if not env:
213
+ return {}
214
+
215
+ base_config: Dict[str, Any] = {}
216
+
217
+ # Validate existing values first
218
+ if existing_values:
219
+ is_valid, validated_config = validate_config(connection, existing_values)
220
+ if is_valid and validated_config is not None:
221
+ return validated_config
222
+ # Use partially validated config as base if validation failed
223
+ base_config = validated_config or {}
224
+
225
+ required = set(schema.required if schema else [])
226
+
227
+ rprint("[bold blue]Please provide the following configuration values:[/bold blue]")
228
+
229
+ for key, prop_details in env.items():
230
+ # Skip if value already exists and is not None in the base_config
231
+ if key in base_config and base_config[key] is not None:
232
+ continue
233
+
234
+ # Prompt if missing
235
+ value = await prompt_for_config_value(key, prop_details, required)
236
+
237
+ # Handle skipped required fields after prompt
238
+ if value is None and key in required:
239
+ # This case should ideally be handled within prompt_for_config_value
240
+ # but as a fallback:
241
+ rprint(f"[bold red]Error: Required configuration '{key}' was not provided.[/bold red]")
242
+ raise ValueError(f"Missing required configuration: {key}")
243
+
244
+ # Assign even if None (for optional fields skipped)
245
+ base_config[key] = value
246
+
247
+ # Final format and validation after collecting all values
248
+ try:
249
+ return format_config_values(connection, base_config)
250
+ except ValueError as e:
251
+ rprint(f"[bold red]Configuration error:[/bold red] {e}")
252
+ # Return the collected (potentially incomplete/invalid) config
253
+ # or raise the error depending on desired strictness
254
+ return base_config
255
+
256
+
257
+ def choose_stdio_connection(connections: List[ConnectionDetails]) -> Optional[ConnectionDetails]:
258
+ """Chooses the best stdio connection from a list based on priority."""
259
+ stdio_connections = [conn for conn in connections if conn.type == "stdio"]
260
+ if not stdio_connections:
261
+ return None
262
+
263
+ priority_order = ["npx", "uvx", "docker"] # Assuming similar priority in Python context
264
+
265
+ # Prioritize published connections first
266
+ for priority in priority_order:
267
+ for conn in stdio_connections:
268
+ stdio_func = conn.stdioFunction
269
+ if (
270
+ conn.published
271
+ and isinstance(stdio_func, list)
272
+ and stdio_func
273
+ and isinstance(stdio_func[0], str)
274
+ and stdio_func[0].startswith(priority)
275
+ ):
276
+ return conn
277
+
278
+ # If no published connection found, check non-published
279
+ for priority in priority_order:
280
+ for conn in stdio_connections:
281
+ stdio_func = conn.stdioFunction
282
+ if (
283
+ isinstance(stdio_func, list)
284
+ and stdio_func
285
+ and isinstance(stdio_func[0], str)
286
+ and stdio_func[0].startswith(priority)
287
+ ):
288
+ return conn
289
+
290
+ # Fallback to the first stdio connection if no priority match
291
+ return stdio_connections[0]
292
+
293
+
294
+ def choose_connection(server: RegistryServer) -> ConnectionDetails:
295
+ """Chooses the most suitable connection for a server."""
296
+ connections = server.connections # Directly access the dataclass attribute
297
+ if not connections:
298
+ raise ValueError(f"No connection configurations found for server {server.qualifiedName}")
299
+
300
+ # Prefer stdio for local servers if available
301
+ if not server.remote: # Directly access the dataclass attribute
302
+ stdio_connection = choose_stdio_connection(connections)
303
+ if stdio_connection:
304
+ return stdio_connection
305
+
306
+ # Otherwise, look for WebSocket (ws) connection
307
+ ws_connection = next((conn for conn in connections if conn.type == "ws"), None)
308
+ if ws_connection:
309
+ return ws_connection
310
+
311
+ # Fallback: try stdio again (even for remote, if ws wasn't found)
312
+ stdio_connection = choose_stdio_connection(connections)
313
+ if stdio_connection:
314
+ return stdio_connection
315
+
316
+ # Final fallback: return the first connection whatever it is
317
+ return connections[0]
318
+
319
+
320
+ def normalize_server_id(server_id: str) -> str:
321
+ """Normalizes server ID by replacing the first slash after '@' with a dash."""
322
+ if server_id.startswith("@"):
323
+ parts = server_id.split("/", 1)
324
+ if len(parts) == 2:
325
+ return f"{parts[0]}-{parts[1]}"
326
+ return server_id
327
+
328
+
329
+ def denormalize_server_id(normalized_id: str) -> str:
330
+ """Converts a normalized server ID back to its original form (with slash)."""
331
+ if normalized_id.startswith("@"):
332
+ parts = normalized_id.split("-", 1) # Split only on the first dash
333
+ # Check if the first part looks like a scope (@scope)
334
+ if len(parts) == 2 and parts[0].startswith("@") and "/" not in parts[0]:
335
+ return f"{parts[0]}/{parts[1]}"
336
+ return normalized_id
337
+
338
+
339
+ def get_server_name(server_id: str) -> str:
340
+ """Extracts the server name part from a potentially scoped server ID."""
341
+ if server_id.startswith("@") and "/" in server_id:
342
+ return server_id.split("/", 1)[1]
343
+ return server_id
344
+
345
+
346
+ def format_run_config_values(
347
+ connection: ConnectionDetails,
348
+ config_values: Optional[Dict[str, Any]] = None,
349
+ ) -> Dict[str, Any]:
350
+ """Formats config values specifically for the 'run' command,
351
+ allowing empty strings for missing required fields."""
352
+ formatted_values: Dict[str, Any] = {}
353
+ config_values = config_values or {}
354
+
355
+ schema = connection.configSchema or {}
356
+ env = schema.env if schema else {}
357
+ if not env:
358
+ return config_values
359
+
360
+ required: Set[str] = set(schema.required if schema else [])
361
+
362
+ for key, prop_details in env.items():
363
+ value = config_values.get(key)
364
+ prop_type = prop_details.type
365
+ final_value: Any
366
+
367
+ if value is not None:
368
+ final_value = value
369
+ elif key not in required:
370
+ final_value = prop_details.default
371
+ else:
372
+ # Key is required but value is missing: use empty string
373
+ final_value = ""
374
+
375
+ # Handle cases where even the default might be None
376
+ if final_value is None:
377
+ # If required use empty string, otherwise None is acceptable for optional
378
+ formatted_values[key] = "" if key in required else None
379
+ continue
380
+
381
+ try:
382
+ formatted_values[key] = convert_value_to_type(final_value, prop_type)
383
+ except ValueError:
384
+ # If conversion fails, use empty string if required, None otherwise
385
+ formatted_values[key] = "" if key in required else None
386
+
387
+ return formatted_values
388
+
389
+ # Implementation for formatServerConfig with proxy command support
390
+
391
+
392
+ def format_server_config(
393
+ qualified_name: str,
394
+ user_config: Dict[str, Any],
395
+ api_key: Optional[str] = None,
396
+ config_needed: bool = True,
397
+ ) -> ConfiguredServer:
398
+ """
399
+ Formats server config into a command structure with proxy command support.
400
+
401
+ Args:
402
+ qualified_name: The package identifier (被代理的命令标识)
403
+ user_config: User configuration values
404
+ api_key: Optional API key
405
+ config_needed: Whether config flag is needed
406
+
407
+ Returns:
408
+ ConfiguredServer instance with command and args and env
409
+ """
410
+ # 固定使用 uv 命令,因为代理是用 Python 写的
411
+ command = "uv" # 固定使用 uv 作为 Python 包管理器
412
+ proxy_package = "@mcpspace/proxy" # 默认代理包
413
+
414
+ # 构建参数列表
415
+ args = ["-y"]
416
+
417
+ # 添加代理包标识符
418
+ args.append(proxy_package)
419
+
420
+ # 添加 run 命令
421
+ args.append("run")
422
+
423
+ # 添加实际的包标识符(被代理的命令)
424
+ args.append(qualified_name)
425
+
426
+ # 添加配置(如果需要)
427
+ # if config_needed and user_config:
428
+ # args.append("--config")
429
+ # args.append(json.dumps(user_config))
430
+
431
+ # 添加 API 密钥(如果有提供)
432
+ if api_key:
433
+ args.append("--api-key")
434
+ args.append(api_key)
435
+
436
+ # env = user_config.get("env", {}) # Extract 'env' from user_config if available
437
+ return ConfiguredServer(
438
+ command=command,
439
+ args=args,
440
+ env=user_config
441
+ )
cli/utils/logger.py ADDED
@@ -0,0 +1,79 @@
1
+ import logging
2
+ import os
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from ..config.app_config import APP_ENV
6
+
7
+ # Get project root directory
8
+
9
+
10
+ def get_project_root():
11
+ current_path = Path(__file__).resolve()
12
+ for parent in current_path.parents:
13
+ if parent.name == 'cli':
14
+ return parent
15
+ return current_path.parents[2] # Fallback to 2 levels up from logger.py
16
+
17
+
18
+ ROOT_DIR = get_project_root()
19
+
20
+ # Set date format
21
+ date = datetime.now()
22
+ date_str = f"{date.year}-{date.month}-{date.day}"
23
+
24
+
25
+ def create_dir_if_not_exist(dir_name: str):
26
+ """Create directory if it doesn't exist"""
27
+ dir_path = os.path.join(ROOT_DIR, dir_name)
28
+ if not os.path.exists(dir_path):
29
+ os.makedirs(dir_path, exist_ok=True)
30
+
31
+
32
+ # Define log formatter
33
+ class CustomFormatter(logging.Formatter):
34
+ def __init__(self, worker_id: str = 'main'):
35
+ super().__init__(fmt='[%(levelname)s] - [%(asctime)s] [%(worker_id)s] %(message)s',
36
+ datefmt='%Y-%m-%d %H:%M:%S.%f')
37
+ self.worker_id = worker_id
38
+
39
+ def format(self, record):
40
+ record.worker_id = self.worker_id
41
+ return super().format(record)
42
+
43
+
44
+ # Create logs directory
45
+ create_dir_if_not_exist('logs')
46
+ create_dir_if_not_exist('logs/cli')
47
+
48
+ # Create CLI Logger
49
+ cli_logger = logging.getLogger('cli_logger')
50
+ cli_logger.setLevel(logging.DEBUG)
51
+
52
+ # Prevent duplicate logs if logger is imported multiple times
53
+ if not cli_logger.handlers:
54
+ # File handler
55
+ cli_log_file = os.path.join(ROOT_DIR, f'logs/cli/{date_str}.log')
56
+ fh_cli = logging.FileHandler(cli_log_file, encoding='utf-8')
57
+ fh_cli.setLevel(logging.DEBUG)
58
+ fh_cli.setFormatter(CustomFormatter())
59
+ cli_logger.addHandler(fh_cli)
60
+
61
+ # Console handler: 仅在开发环境或DEBUG_STDIO_RUNNER=1时输出到终端
62
+ # if APP_ENV == "dev":
63
+ ch_cli = logging.StreamHandler()
64
+ ch_cli.setLevel(logging.DEBUG)
65
+ ch_cli.setFormatter(CustomFormatter())
66
+ cli_logger.addHandler(ch_cli)
67
+
68
+
69
+ def verbose(message: str) -> None:
70
+ """Logs a verbose message to both file and console (if INFO level)"""
71
+ cli_logger.debug(message)
72
+
73
+
74
+ # For compatibility with existing code
75
+ debug = cli_logger.debug
76
+ info = cli_logger.info
77
+ warning = cli_logger.warning
78
+ error = cli_logger.error
79
+ critical = cli_logger.critical