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.
- cli/__init__.py +0 -0
- cli/commands/__init__.py +0 -0
- cli/commands/install.py +182 -0
- cli/commands/run.py +148 -0
- cli/config/__init__.py +0 -0
- cli/config/app_config.py +11 -0
- cli/config/client_config.py +248 -0
- cli/main.py +12 -0
- cli/mock_servers.json +186 -0
- cli/registry.py +136 -0
- cli/runners/__init__.py +21 -0
- cli/runners/command_runner.py +172 -0
- cli/runners/stdio_runner.py +494 -0
- cli/runners/stream_http_runner.py +166 -0
- cli/runners/ws_runner.py +43 -0
- cli/types/__init__.py +0 -0
- cli/types/registry.py +69 -0
- cli/utils/__init__.py +0 -0
- cli/utils/client.py +0 -0
- cli/utils/config.py +441 -0
- cli/utils/logger.py +79 -0
- cli/utils/runtime.py +163 -0
- py_mcpdock_cli-1.0.13.dist-info/METADATA +28 -0
- py_mcpdock_cli-1.0.13.dist-info/RECORD +27 -0
- py_mcpdock_cli-1.0.13.dist-info/WHEEL +5 -0
- py_mcpdock_cli-1.0.13.dist-info/entry_points.txt +2 -0
- py_mcpdock_cli-1.0.13.dist-info/top_level.txt +1 -0
cli/runners/ws_runner.py
ADDED
@@ -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
|