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/__init__.py
ADDED
File without changes
|
cli/commands/__init__.py
ADDED
File without changes
|
cli/commands/install.py
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
import click
|
2
|
+
import json
|
3
|
+
import asyncio
|
4
|
+
from typing import Dict, Any, Optional
|
5
|
+
|
6
|
+
from rich import print as rprint
|
7
|
+
from rich.console import Console
|
8
|
+
from rich.spinner import Spinner
|
9
|
+
|
10
|
+
from ..utils.logger import verbose, info, debug, error, warning
|
11
|
+
from ..registry import resolve_package
|
12
|
+
from ..utils.runtime import ensure_uv_installed, ensure_bun_installed, check_and_notify_remote_server
|
13
|
+
from ..utils.config import choose_connection, collect_config_values, get_server_name, format_server_config, format_config_values
|
14
|
+
from ..config.client_config import read_config, write_config
|
15
|
+
from ..types.registry import ConfiguredServer, ClientConfig
|
16
|
+
|
17
|
+
|
18
|
+
async def validate_config_values(
|
19
|
+
connection: Any,
|
20
|
+
existing_values: Optional[Dict[str, Any]] = None,
|
21
|
+
) -> Dict[str, Any]:
|
22
|
+
"""
|
23
|
+
Validates config values without prompting for user input.
|
24
|
+
Only checks if required values are present.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
connection: Connection details containing schema
|
28
|
+
existing_values: Existing config values to validate
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
Dictionary with validated config values
|
32
|
+
|
33
|
+
Raises:
|
34
|
+
ValueError: If required configuration values are missing
|
35
|
+
"""
|
36
|
+
schema = connection.configSchema or {}
|
37
|
+
env = schema.env if schema else {}
|
38
|
+
if not env:
|
39
|
+
return {}
|
40
|
+
|
41
|
+
# Use existing values or empty dict
|
42
|
+
config_values = existing_values or {}
|
43
|
+
|
44
|
+
# Get list of required env variables
|
45
|
+
required = set(schema.required if schema else [])
|
46
|
+
|
47
|
+
# Check if all required env variables are provided
|
48
|
+
missing_required = []
|
49
|
+
for key in required:
|
50
|
+
if key not in config_values or config_values[key] is None:
|
51
|
+
missing_required.append(key)
|
52
|
+
|
53
|
+
# Raise error if any required env variables are missing
|
54
|
+
if missing_required:
|
55
|
+
missing_fields = ", ".join(missing_required)
|
56
|
+
raise ValueError(f"Missing required configuration values: {missing_fields}")
|
57
|
+
|
58
|
+
# Format and return the validated config
|
59
|
+
return format_config_values(connection, config_values)
|
60
|
+
|
61
|
+
|
62
|
+
async def install_mcp_server(
|
63
|
+
package_identifier: str,
|
64
|
+
target_client: Optional[str] = None,
|
65
|
+
initial_config: Optional[Dict[str, Any]] = None,
|
66
|
+
api_key: Optional[str] = None
|
67
|
+
) -> None:
|
68
|
+
"""
|
69
|
+
Installs and configures an AI server package for a specified client.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
package_identifier: The fully qualified name of the AI server package
|
73
|
+
target_client: The AI client to configure the server for
|
74
|
+
initial_config: Optional pre-defined configuration values
|
75
|
+
api_key: Optional API key for fetching saved configurations
|
76
|
+
"""
|
77
|
+
target_client = target_client or "claude" # Default to Claude if no client specified
|
78
|
+
verbose(f"Initiating installation of {package_identifier} for {target_client}")
|
79
|
+
|
80
|
+
console = Console()
|
81
|
+
|
82
|
+
# Create and start spinner for package resolution
|
83
|
+
with console.status(f"Resolving {package_identifier}...", spinner="dots") as status:
|
84
|
+
try:
|
85
|
+
# Resolve the package (fetch server metadata)
|
86
|
+
verbose(f"Resolving package: {package_identifier}")
|
87
|
+
server_data = await resolve_package(package_identifier)
|
88
|
+
verbose(f"Package resolved successfully: {server_data.qualifiedName}")
|
89
|
+
|
90
|
+
# Choose the appropriate connection type
|
91
|
+
verbose("Choosing connection type...")
|
92
|
+
connection = choose_connection(server_data)
|
93
|
+
verbose(f"Selected connection type: {connection.type}")
|
94
|
+
|
95
|
+
# Check for required runtimes and install if needed
|
96
|
+
# Commented out as these are specific to JS environment
|
97
|
+
# await ensure_uv_installed(connection)
|
98
|
+
# await ensure_bun_installed(connection)
|
99
|
+
|
100
|
+
# Inform users of remote server installation if applicable
|
101
|
+
is_remote = check_and_notify_remote_server(server_data)
|
102
|
+
if is_remote:
|
103
|
+
verbose("Remote server detected, notification displayed")
|
104
|
+
|
105
|
+
# Get the validated config values, no prompting
|
106
|
+
status.update(f"Validating configuration for {package_identifier}...")
|
107
|
+
collected_config_values = await validate_config_values(connection, initial_config)
|
108
|
+
|
109
|
+
# Determine if we need to pass config flag
|
110
|
+
config_flag_needed = initial_config is not None
|
111
|
+
|
112
|
+
verbose(f"Config values validated: {json.dumps(collected_config_values, indent=2)}")
|
113
|
+
verbose(f"Using config flag: {config_flag_needed}")
|
114
|
+
|
115
|
+
# Format the server configuration
|
116
|
+
verbose("Formatting server configuration...")
|
117
|
+
server_config = format_server_config(
|
118
|
+
package_identifier,
|
119
|
+
collected_config_values,
|
120
|
+
api_key,
|
121
|
+
config_flag_needed,
|
122
|
+
)
|
123
|
+
verbose(f"Formatted server config: {json.dumps(server_config.__dict__, indent=2)}")
|
124
|
+
|
125
|
+
# Read existing config from client
|
126
|
+
status.update(f"Installing for {target_client}...")
|
127
|
+
verbose(f"Reading configuration for client: {target_client}")
|
128
|
+
client_config = read_config(target_client)
|
129
|
+
|
130
|
+
# Get normalized server name to use as key
|
131
|
+
server_name = get_server_name(package_identifier)
|
132
|
+
verbose(f"Normalized server ID: {server_name}")
|
133
|
+
|
134
|
+
# Update client configuration with new server
|
135
|
+
verbose("Updating client configuration...")
|
136
|
+
if not isinstance(client_config.mcpServers, dict):
|
137
|
+
# Initialize if needed
|
138
|
+
client_config.mcpServers = {}
|
139
|
+
|
140
|
+
# Add the new server config
|
141
|
+
client_config.mcpServers[server_name] = server_config
|
142
|
+
|
143
|
+
# Write updated configuration
|
144
|
+
verbose("Writing updated configuration...")
|
145
|
+
write_config(client_config, target_client)
|
146
|
+
verbose("Configuration successfully written")
|
147
|
+
|
148
|
+
rprint(f"[green]{package_identifier} successfully installed for {target_client}[/green]")
|
149
|
+
|
150
|
+
# No prompt for client restart
|
151
|
+
verbose("Installation completed successfully")
|
152
|
+
|
153
|
+
except Exception as e:
|
154
|
+
verbose(f"Installation error: {str(e)}")
|
155
|
+
rprint(f"[red]Failed to install {package_identifier}[/red]")
|
156
|
+
rprint(f"[red]Error: {str(e)}[/red]")
|
157
|
+
raise
|
158
|
+
|
159
|
+
|
160
|
+
@click.command("install")
|
161
|
+
@click.argument("mcp_server", type=click.STRING)
|
162
|
+
@click.option("--client", type=click.STRING, help="Specify the target AI client")
|
163
|
+
@click.option("--env", "env_json", type=click.STRING, help="Provide configuration as JSON (bypasses interactive prompts)")
|
164
|
+
@click.option("--api-key", type=click.STRING, help="Provide an API key for fetching saved configurations")
|
165
|
+
def install(mcp_server: str, client: Optional[str], env_json: Optional[str], api_key: Optional[str]):
|
166
|
+
"""Install an AI mcp-server with optional configuration."""
|
167
|
+
click.echo(f"Attempting to install {mcp_server}...")
|
168
|
+
user_config = None
|
169
|
+
if env_json:
|
170
|
+
try:
|
171
|
+
user_config = json.loads(env_json)
|
172
|
+
click.echo(f"Using provided config: {user_config}")
|
173
|
+
except json.JSONDecodeError as e:
|
174
|
+
click.echo(f"Error: Invalid JSON provided for --config: {e}", err=True)
|
175
|
+
return
|
176
|
+
|
177
|
+
try:
|
178
|
+
# Run the async installation process
|
179
|
+
asyncio.run(install_mcp_server(mcp_server, client, user_config, api_key))
|
180
|
+
except Exception as e:
|
181
|
+
click.echo(f"Installation failed: {str(e)}", err=True)
|
182
|
+
return 1
|
cli/commands/run.py
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
import click
|
2
|
+
import json
|
3
|
+
import asyncio
|
4
|
+
from typing import Dict, Any, Optional
|
5
|
+
|
6
|
+
from rich import print as rprint
|
7
|
+
from rich.console import Console
|
8
|
+
|
9
|
+
from ..utils.logger import verbose
|
10
|
+
from ..registry import resolve_package
|
11
|
+
from ..utils.config import choose_connection, format_run_config_values
|
12
|
+
from ..utils.runtime import check_and_notify_remote_server
|
13
|
+
from ..types.registry import RegistryServer, ConnectionDetails
|
14
|
+
from ..runners import create_stdio_runner, create_ws_runner, run_package_with_command, create_stream_http_runner
|
15
|
+
|
16
|
+
|
17
|
+
# These runner functions have been moved to their respective modules in the runners package
|
18
|
+
|
19
|
+
async def pick_server_and_run(
|
20
|
+
server_details: RegistryServer,
|
21
|
+
config: Dict[str, Any],
|
22
|
+
api_key: Optional[str] = None,
|
23
|
+
analytics_enabled: bool = False
|
24
|
+
) -> None:
|
25
|
+
"""
|
26
|
+
Selects the appropriate runner and starts the server.
|
27
|
+
"""
|
28
|
+
connection = choose_connection(server_details)
|
29
|
+
|
30
|
+
if connection.type == "ws":
|
31
|
+
if not connection.deploymentUrl:
|
32
|
+
raise ValueError("Missing deployment URL")
|
33
|
+
await create_ws_runner(connection.deploymentUrl, config, api_key)
|
34
|
+
elif connection.type == "stdio":
|
35
|
+
await create_stdio_runner(server_details, config, api_key, analytics_enabled)
|
36
|
+
elif connection.type == "stream-http":
|
37
|
+
await create_stream_http_runner(server_details, config, api_key, analytics_enabled)
|
38
|
+
else:
|
39
|
+
raise ValueError(f"Unsupported connection type: {connection.type}")
|
40
|
+
|
41
|
+
|
42
|
+
async def run_server(
|
43
|
+
qualified_name: str,
|
44
|
+
config: Dict[str, Any],
|
45
|
+
api_key: Optional[str] = None
|
46
|
+
) -> None:
|
47
|
+
"""
|
48
|
+
Runs a server with the specified configuration.
|
49
|
+
|
50
|
+
The qualified_name can be in these formats:
|
51
|
+
- Standard MCP server name: "company/package"
|
52
|
+
- uv command: "uv:package_name [args]"
|
53
|
+
- npx command: "npx:package_name [args]"
|
54
|
+
"""
|
55
|
+
try:
|
56
|
+
# # Check if this is a special command format (uv: or npx:)
|
57
|
+
# if qualified_name.startswith("uv:") or qualified_name.startswith("npx:"):
|
58
|
+
# command_parts = qualified_name.split(":", 1)
|
59
|
+
# command_type = command_parts[0].lower()
|
60
|
+
# package_spec = command_parts[1]
|
61
|
+
|
62
|
+
# # Extract package name and args if provided
|
63
|
+
# package_parts = package_spec.split(" ", 1)
|
64
|
+
# package_name = package_parts[0]
|
65
|
+
# args = package_parts[1].split() if len(package_parts) > 1 else []
|
66
|
+
|
67
|
+
# verbose(f"Running {command_type} command for package: {package_name}")
|
68
|
+
# await run_package_with_command(
|
69
|
+
# command_type,
|
70
|
+
# package_name,
|
71
|
+
# args,
|
72
|
+
# config,
|
73
|
+
# api_key
|
74
|
+
# )
|
75
|
+
# return
|
76
|
+
|
77
|
+
# Initialize settings for regular MCP server
|
78
|
+
verbose("Initializing runtime environment settings")
|
79
|
+
|
80
|
+
# Resolve server package
|
81
|
+
verbose(f"Resolving server package: {qualified_name}")
|
82
|
+
resolved_server = await resolve_package(qualified_name)
|
83
|
+
|
84
|
+
if not resolved_server:
|
85
|
+
raise ValueError(f"Could not resolve server: {qualified_name}")
|
86
|
+
|
87
|
+
# Format the final configuration with validation
|
88
|
+
connection = choose_connection(resolved_server)
|
89
|
+
final_config = format_run_config_values(connection, config)
|
90
|
+
|
91
|
+
# Inform about remote server if applicable
|
92
|
+
# check_and_notify_remote_server(resolved_server)
|
93
|
+
|
94
|
+
rprint(f"[blue][Runner] Connecting to server:[/blue] {resolved_server.qualifiedName}")
|
95
|
+
rprint(f"Connection types: {[c.type for c in resolved_server.connections]}")
|
96
|
+
|
97
|
+
# Assume analytics is disabled for now
|
98
|
+
analytics_enabled = False
|
99
|
+
|
100
|
+
# Run the server with the appropriate runner
|
101
|
+
await pick_server_and_run(
|
102
|
+
resolved_server,
|
103
|
+
final_config,
|
104
|
+
api_key,
|
105
|
+
analytics_enabled
|
106
|
+
)
|
107
|
+
except Exception as e:
|
108
|
+
rprint(f"[red][Runner] Fatal error:[/red] {str(e)}")
|
109
|
+
raise
|
110
|
+
|
111
|
+
|
112
|
+
@click.command("run")
|
113
|
+
@click.argument("mcp_server", type=click.STRING)
|
114
|
+
@click.option("--config", "config_json", type=click.STRING, help="Provide JSON format configuration")
|
115
|
+
@click.option("--api-key", type=click.STRING, help="API key for retrieving saved configuration")
|
116
|
+
def run(mcp_server: str, config_json: Optional[str], api_key: Optional[str]):
|
117
|
+
"""Run an AI MCP server."""
|
118
|
+
click.echo(f"Attempting to run {mcp_server}...")
|
119
|
+
server_config = None
|
120
|
+
|
121
|
+
# Parse command line provided configuration
|
122
|
+
if config_json:
|
123
|
+
try:
|
124
|
+
server_config = json.loads(config_json)
|
125
|
+
click.echo(f"Using provided config: {server_config}")
|
126
|
+
except json.JSONDecodeError as e:
|
127
|
+
click.echo(f"Error: Invalid JSON provided for --config: {e}", err=True)
|
128
|
+
return 1
|
129
|
+
|
130
|
+
# If no config provided, use empty dict
|
131
|
+
if server_config is None:
|
132
|
+
verbose("No config provided, running with empty config")
|
133
|
+
server_config = {}
|
134
|
+
|
135
|
+
if api_key:
|
136
|
+
verbose(f"API key provided: {'*' * len(api_key)}")
|
137
|
+
|
138
|
+
console = Console()
|
139
|
+
|
140
|
+
try:
|
141
|
+
# with console.status(f"Starting {mcp_server}...", spinner="dots") as status:
|
142
|
+
# Run the server asynchronously
|
143
|
+
verbose('running server....')
|
144
|
+
asyncio.run(run_server(mcp_server, server_config, api_key))
|
145
|
+
# status.update(f"Successfully started {mcp_server}")
|
146
|
+
except Exception as e:
|
147
|
+
click.echo(f"Run failed: {str(e)}", err=True)
|
148
|
+
return 1
|
cli/config/__init__.py
ADDED
File without changes
|
cli/config/app_config.py
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# config/app_config.py
|
2
|
+
import os
|
3
|
+
from dotenv import load_dotenv
|
4
|
+
|
5
|
+
# Load environment variables from a .env file
|
6
|
+
load_dotenv()
|
7
|
+
|
8
|
+
|
9
|
+
APP_NAME = os.getenv("APP_NAME", "My Application")
|
10
|
+
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
11
|
+
APP_ENV = os.getenv('ENV')
|
@@ -0,0 +1,248 @@
|
|
1
|
+
import os
|
2
|
+
import json
|
3
|
+
import sys
|
4
|
+
import subprocess
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Dict, Any, List, Optional, Union, Literal, TypedDict
|
7
|
+
|
8
|
+
# Import types from the registry module
|
9
|
+
from ..types.registry import ClientConfig, ConfiguredServer
|
10
|
+
# Import logger
|
11
|
+
from ..utils.logger import verbose
|
12
|
+
|
13
|
+
# Define types for configuration targets
|
14
|
+
|
15
|
+
|
16
|
+
class ClientFileTarget(TypedDict):
|
17
|
+
type: Literal["file"]
|
18
|
+
path: Path
|
19
|
+
|
20
|
+
|
21
|
+
class ClientCommandTarget(TypedDict):
|
22
|
+
type: Literal["command"]
|
23
|
+
command: str
|
24
|
+
# Potentially add args template if needed later
|
25
|
+
|
26
|
+
|
27
|
+
ClientInstallTarget = Union[ClientFileTarget, ClientCommandTarget]
|
28
|
+
|
29
|
+
# Determine platform-specific paths using pathlib
|
30
|
+
home_dir = Path.home()
|
31
|
+
|
32
|
+
if sys.platform == "win32":
|
33
|
+
base_dir = Path(os.environ.get("APPDATA", home_dir / "AppData" / "Roaming"))
|
34
|
+
vscode_storage_path = Path("Code") / "User" / "globalStorage"
|
35
|
+
code_command = "code.cmd"
|
36
|
+
code_insiders_command = "code-insiders.cmd"
|
37
|
+
elif sys.platform == "darwin":
|
38
|
+
base_dir = home_dir / "Library" / "Application Support"
|
39
|
+
vscode_storage_path = Path("Code") / "User" / "globalStorage"
|
40
|
+
code_command = "code"
|
41
|
+
code_insiders_command = "code-insiders"
|
42
|
+
else: # Assume Linux/other Unix-like
|
43
|
+
base_dir = Path(os.environ.get("XDG_CONFIG_HOME", home_dir / ".config"))
|
44
|
+
vscode_storage_path = Path("Code/User/globalStorage") # Note: Path combines these
|
45
|
+
code_command = "code"
|
46
|
+
code_insiders_command = "code-insiders"
|
47
|
+
|
48
|
+
default_claude_path = base_dir / "Claude" / "claude_desktop_config.json"
|
49
|
+
|
50
|
+
# Define client paths using platform-specific base directories
|
51
|
+
# Using lowercase keys for normalization
|
52
|
+
CLIENT_PATHS: Dict[str, ClientInstallTarget] = {
|
53
|
+
"claude": {"type": "file", "path": default_claude_path},
|
54
|
+
"cline": {
|
55
|
+
"type": "file",
|
56
|
+
"path": base_dir / vscode_storage_path / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
|
57
|
+
},
|
58
|
+
"roo-cline": {
|
59
|
+
"type": "file",
|
60
|
+
"path": base_dir / vscode_storage_path / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json",
|
61
|
+
},
|
62
|
+
"windsurf": {"type": "file", "path": home_dir / ".codeium" / "windsurf" / "mcp_config.json"},
|
63
|
+
"witsy": {"type": "file", "path": base_dir / "Witsy" / "settings.json"},
|
64
|
+
"enconvo": {"type": "file", "path": home_dir / ".config" / "enconvo" / "mcp_config.json"},
|
65
|
+
"cursor": {"type": "file", "path": home_dir / ".cursor" / "mcp.json"},
|
66
|
+
"vscode": {"type": "command", "command": code_command},
|
67
|
+
"vscode-insiders": {"type": "command", "command": code_insiders_command},
|
68
|
+
}
|
69
|
+
|
70
|
+
|
71
|
+
def get_config_target(client: Optional[str] = None) -> ClientInstallTarget:
|
72
|
+
"""Gets the configuration target (file path or command) for a given client."""
|
73
|
+
normalized_client = (client or "claude").lower()
|
74
|
+
verbose(f"Getting config target for client: {normalized_client}")
|
75
|
+
|
76
|
+
target = CLIENT_PATHS.get(normalized_client)
|
77
|
+
|
78
|
+
if target:
|
79
|
+
verbose(f"Config target resolved to: {target}")
|
80
|
+
return target
|
81
|
+
else:
|
82
|
+
# Fallback for unknown clients (similar to JS version)
|
83
|
+
fallback_path = default_claude_path.parent.parent / (client or "claude") / f"{normalized_client}_config.json"
|
84
|
+
fallback_target: ClientFileTarget = {"type": "file", "path": fallback_path}
|
85
|
+
verbose(f"Client '{normalized_client}' not predefined, using fallback path: {fallback_target}")
|
86
|
+
return fallback_target
|
87
|
+
|
88
|
+
|
89
|
+
def read_config(client: Optional[str] = None) -> ClientConfig:
|
90
|
+
"""Reads the configuration for the specified client."""
|
91
|
+
client_name = (client or "claude").lower()
|
92
|
+
verbose(f"Reading config for client: {client_name}")
|
93
|
+
target = get_config_target(client_name)
|
94
|
+
|
95
|
+
if target["type"] == "command":
|
96
|
+
verbose(f"Client '{client_name}' uses command-based config. Reading not supported.")
|
97
|
+
# Command-based installers don't support reading config back easily
|
98
|
+
return ClientConfig(mcpServers={})
|
99
|
+
|
100
|
+
config_path = target["path"]
|
101
|
+
verbose(f"Checking if config file exists at: {config_path}")
|
102
|
+
if not config_path.exists():
|
103
|
+
verbose("Config file not found, returning default empty config.")
|
104
|
+
return ClientConfig(mcpServers={})
|
105
|
+
|
106
|
+
try:
|
107
|
+
verbose("Reading config file content.")
|
108
|
+
raw_content = config_path.read_text(encoding="utf-8")
|
109
|
+
raw_config = json.loads(raw_content)
|
110
|
+
verbose(f"Config loaded successfully: {json.dumps(raw_config, indent=2)}")
|
111
|
+
|
112
|
+
# Ensure mcpServers key exists, return as ClientConfig object
|
113
|
+
mcp_servers_data = raw_config.get("mcpServers", {})
|
114
|
+
# Basic validation/conversion if needed, assuming structure matches ConfiguredServer for now
|
115
|
+
configured_servers = {
|
116
|
+
name: ConfiguredServer(**server_data)
|
117
|
+
for name, server_data in mcp_servers_data.items()
|
118
|
+
if isinstance(server_data, dict) and "command" in server_data and "args" in server_data
|
119
|
+
}
|
120
|
+
|
121
|
+
# Include other top-level keys from the config file
|
122
|
+
other_config = {k: v for k, v in raw_config.items() if k != "mcpServers"}
|
123
|
+
|
124
|
+
return ClientConfig(mcpServers=configured_servers, **other_config)
|
125
|
+
|
126
|
+
except json.JSONDecodeError as e:
|
127
|
+
verbose(f"Error decoding JSON from {config_path}: {e}")
|
128
|
+
return ClientConfig(mcpServers={})
|
129
|
+
except Exception as e:
|
130
|
+
verbose(f"Error reading config file {config_path}: {e}")
|
131
|
+
return ClientConfig(mcpServers={})
|
132
|
+
|
133
|
+
|
134
|
+
def _write_config_command(config: ClientConfig, target: ClientCommandTarget) -> None:
|
135
|
+
"""Writes configuration using a command (e.g., for VS Code)."""
|
136
|
+
command = target["command"]
|
137
|
+
args: List[str] = []
|
138
|
+
|
139
|
+
# Convert ConfiguredServer back to dict for JSON stringify
|
140
|
+
for name, server in config.mcpServers.items():
|
141
|
+
server_dict = {"command": server.command, "args": server.args, "name": name}
|
142
|
+
args.extend(["--add-mcp", json.dumps(server_dict)])
|
143
|
+
|
144
|
+
full_command = [command] + args
|
145
|
+
verbose(f"Running command: {' '.join(full_command)}")
|
146
|
+
|
147
|
+
try:
|
148
|
+
# Use shell=True cautiously if command might not be directly executable
|
149
|
+
# Or better, ensure the command is in PATH
|
150
|
+
result = subprocess.run(full_command, capture_output=True, text=True, check=True, encoding='utf-8')
|
151
|
+
verbose(f"Executed command successfully. Output:\n{result.stdout}")
|
152
|
+
if result.stderr:
|
153
|
+
verbose(f"Command stderr:\n{result.stderr}")
|
154
|
+
except FileNotFoundError:
|
155
|
+
verbose(f"Error: Command '{command}' not found. Make sure it is installed and in your PATH.")
|
156
|
+
raise FileNotFoundError(f"Command '{command}' not found.")
|
157
|
+
except subprocess.CalledProcessError as e:
|
158
|
+
verbose(f"Error executing command. Return code: {e.returncode}")
|
159
|
+
verbose(f"Stdout:\n{e.stdout}")
|
160
|
+
verbose(f"Stderr:\n{e.stderr}")
|
161
|
+
raise RuntimeError(f"Command '{command}' failed: {e.stderr or e.stdout}")
|
162
|
+
except Exception as e:
|
163
|
+
verbose(f"An unexpected error occurred while running the command: {e}")
|
164
|
+
raise
|
165
|
+
|
166
|
+
|
167
|
+
def _write_config_file(config: ClientConfig, target: ClientFileTarget) -> None:
|
168
|
+
"""Writes configuration to a file, merging with existing content."""
|
169
|
+
config_path = target["path"]
|
170
|
+
config_dir = config_path.parent
|
171
|
+
|
172
|
+
verbose(f"Ensuring config directory exists: {config_dir}")
|
173
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
174
|
+
|
175
|
+
existing_config_data: Dict[str, Any] = {}
|
176
|
+
if config_path.exists():
|
177
|
+
try:
|
178
|
+
verbose("Reading existing config file for merging.")
|
179
|
+
existing_config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
180
|
+
verbose(f"Existing config loaded: {json.dumps(existing_config_data, indent=2)}")
|
181
|
+
except json.JSONDecodeError as e:
|
182
|
+
verbose(f"Error reading existing config file {config_path} for merge: {e}. Will overwrite.")
|
183
|
+
except Exception as e:
|
184
|
+
verbose(f"Error reading existing config file {config_path}: {e}. Will overwrite.")
|
185
|
+
|
186
|
+
# Prepare the config to be written (convert dataclasses back to dicts)
|
187
|
+
config_to_write = {
|
188
|
+
**config.__dict__, # Include other top-level keys if ClientConfig has them
|
189
|
+
"mcpServers": {
|
190
|
+
name: {"command": server.command, "args": server.args, "env": server.env}
|
191
|
+
for name, server in config.mcpServers.items()
|
192
|
+
}
|
193
|
+
}
|
194
|
+
|
195
|
+
# Merge configurations
|
196
|
+
verbose("Merging configurations.")
|
197
|
+
merged_config = {
|
198
|
+
**existing_config_data,
|
199
|
+
**config_to_write,
|
200
|
+
# Ensure mcpServers are merged correctly
|
201
|
+
"mcpServers": {
|
202
|
+
**(existing_config_data.get("mcpServers") or {}),
|
203
|
+
**(config_to_write.get("mcpServers") or {}),
|
204
|
+
}
|
205
|
+
}
|
206
|
+
verbose(f"Merged config: {json.dumps(merged_config, indent=2)}")
|
207
|
+
|
208
|
+
try:
|
209
|
+
verbose(f"Writing config to file: {config_path}")
|
210
|
+
config_path.write_text(json.dumps(merged_config, indent=2), encoding="utf-8")
|
211
|
+
verbose(f"Configuration successfully written to {config_path}")
|
212
|
+
except Exception as e:
|
213
|
+
verbose(f"Error writing config file {config_path}: {e}")
|
214
|
+
raise IOError(f"Failed to write configuration to {config_path}") from e
|
215
|
+
|
216
|
+
|
217
|
+
def write_config(config: ClientConfig, client: Optional[str] = None) -> None:
|
218
|
+
"""Writes the configuration for the specified client, either to a file or via command."""
|
219
|
+
client_name = (client or "claude").lower()
|
220
|
+
verbose(f"Writing config for client: {client_name}")
|
221
|
+
verbose(f"Config data: {config}") # Dataclass repr is usually good
|
222
|
+
|
223
|
+
if not isinstance(config, ClientConfig) or not isinstance(config.mcpServers, dict):
|
224
|
+
verbose("Invalid config object provided.")
|
225
|
+
raise TypeError("Invalid ClientConfig object provided to write_config")
|
226
|
+
|
227
|
+
target = get_config_target(client_name)
|
228
|
+
|
229
|
+
if target["type"] == "command":
|
230
|
+
_write_config_command(config, target)
|
231
|
+
else:
|
232
|
+
# Prepare the configuration to be written
|
233
|
+
cleaned_config = {
|
234
|
+
**config.__dict__,
|
235
|
+
"mcpServers": {
|
236
|
+
name: server.__dict__ for name, server in config.mcpServers.items()
|
237
|
+
}
|
238
|
+
}
|
239
|
+
# Deserialize back into ConfiguredServer objects after writing
|
240
|
+
deserialized_config = ClientConfig(
|
241
|
+
**{
|
242
|
+
**cleaned_config,
|
243
|
+
"mcpServers": {
|
244
|
+
name: ConfiguredServer(**server) for name, server in cleaned_config["mcpServers"].items()
|
245
|
+
}
|
246
|
+
}
|
247
|
+
)
|
248
|
+
_write_config_file(deserialized_config, target)
|
cli/main.py
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
import click
|
2
|
+
from .commands.install import install
|
3
|
+
from .commands.run import run
|
4
|
+
|
5
|
+
@click.group()
|
6
|
+
@click.version_option(version="1.0.0", package_name="py-cli") # Corresponds to program.version("1.0.0")
|
7
|
+
def cli():
|
8
|
+
"""A custom CLI tool translated to Python."""
|
9
|
+
pass
|
10
|
+
|
11
|
+
cli.add_command(install)
|
12
|
+
cli.add_command(run)
|