Skip to content

Commit ce98f8f

Browse files
niechenclaude
andcommitted
feat: refactor commands to v2 structure and add HTTP server support
This commit refactors the command structure for MCPM v2 and adds comprehensive HTTP server support: ## Command Structure Changes - Move add/remove commands from target_operations to main commands directory - Rename commands to match v2 syntax: `add` → `install`, `remove` → `uninstall` - Remove v1-specific profile activation logic and complexity - Inline necessary helper functions into command files - Update CLI registration and imports ## HTTP Server Support - Add support for installing HTTP MCP servers via `mcpm install` - HTTP servers create RemoteServerConfig instead of STDIOServerConfig - Add dedicated headers field for HTTP server configuration - Headers support variable substitution (e.g., `"Authorization": "Bearer ${API_TOKEN}"`) - HTTP servers don't require command/args fields ## Smart Argument Filtering - Only prompt for arguments actually referenced in installation method - Scan for `${VARIABLE_NAME}` patterns in all installation fields - HTTP installations without variables prompt for no arguments - Traditional installations only prompt for used variables ## Bug Fixes - Fix AttributeError when accessing .command on RemoteServerConfig - Add proper type checking in run.py and display.py - Ensure HTTP servers are handled correctly throughout the system ## Tests - Update all tests to use new command structure - Add comprehensive tests for HTTP server installation - Add tests for argument filtering functionality - All tests passing with new architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 1885d05 commit ce98f8f

File tree

11 files changed

+412
-259
lines changed

11 files changed

+412
-259
lines changed

src/mcpm/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@
1010

1111
from mcpm.clients.client_config import ClientConfigManager
1212
from mcpm.commands import (
13-
add,
1413
client,
1514
config,
1615
doctor,
1716
edit,
1817
info,
1918
inspect,
19+
install,
2020
list,
2121
migrate,
2222
profile,
23-
remove,
2423
run,
2524
search,
25+
uninstall,
2626
usage,
2727
)
2828
from mcpm.commands.share import share
@@ -120,8 +120,8 @@ def main(ctx, version, help_flag):
120120
main.add_command(search.search)
121121
main.add_command(info.info)
122122
main.add_command(list.list, name="ls")
123-
main.add_command(add.add, name="install")
124-
main.add_command(remove.remove, name="uninstall")
123+
main.add_command(install.install)
124+
main.add_command(uninstall.uninstall)
125125
main.add_command(edit.edit)
126126
main.add_command(run.run)
127127
main.add_command(inspect.inspect)

src/mcpm/commands/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
"""
44

55
__all__ = [
6-
"add",
76
"client",
87
"config",
98
"doctor",
109
"info",
1110
"inspect",
11+
"install",
1212
"list",
1313
"migrate",
1414
"profile",
15-
"remove",
1615
"run",
1716
"search",
17+
"uninstall",
1818
"usage",
1919
]
2020

@@ -27,11 +27,12 @@
2727
doctor,
2828
info,
2929
inspect,
30+
install,
3031
list,
3132
migrate,
3233
profile,
3334
run,
3435
search,
36+
uninstall,
3537
usage,
3638
)
37-
from .target_operations import add, remove

src/mcpm/commands/target_operations/add.py renamed to src/mcpm/commands/install.py

Lines changed: 124 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
from rich.progress import Progress, SpinnerColumn, TextColumn
1616
from rich.prompt import Confirm
1717

18-
from mcpm.commands.target_operations.common import (
19-
global_add_server,
20-
)
18+
from mcpm.global_config import GlobalConfigManager
19+
from mcpm.core.schema import ServerConfig, STDIOServerConfig
20+
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
2121
from mcpm.profile.profile_config import ProfileConfigManager
2222
from mcpm.schemas.full_server_config import FullServerConfig
2323
from mcpm.utils.repository import RepositoryManager
@@ -26,6 +26,7 @@
2626
console = Console()
2727
repo_manager = RepositoryManager()
2828
profile_config_manager = ProfileConfigManager()
29+
global_config_manager = GlobalConfigManager()
2930

3031
# Create a prompt session with custom styling
3132
prompt_session = PromptSession()
@@ -43,6 +44,34 @@
4344
kb = KeyBindings()
4445

4546

47+
def _replace_node_executable(server_config: ServerConfig) -> ServerConfig:
48+
"""Replace node executable with configured alternative if applicable."""
49+
if not isinstance(server_config, STDIOServerConfig):
50+
return server_config
51+
command = server_config.command.strip()
52+
if command not in NODE_EXECUTABLES:
53+
return server_config
54+
config = ConfigManager().get_config()
55+
config_node_executable = config.get("node_executable")
56+
if not config_node_executable:
57+
return server_config
58+
if config_node_executable != command:
59+
console.print(f"[bold cyan]Replace node executable {command} with {config_node_executable}[/]")
60+
server_config.command = config_node_executable
61+
return server_config
62+
63+
64+
def global_add_server(server_config: ServerConfig, force: bool = False) -> bool:
65+
"""Add a server to the global MCPM configuration."""
66+
if global_config_manager.server_exists(server_config.name) and not force:
67+
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in global configuration.")
68+
console.print("Use --force to override.")
69+
return False
70+
71+
server_config = _replace_node_executable(server_config)
72+
return global_config_manager.add_server(server_config, force)
73+
74+
4675
def prompt_with_default(prompt_text, default="", hide_input=False, required=False):
4776
"""Prompt the user with a default value that can be edited directly.
4877
@@ -91,7 +120,7 @@ def prompt_with_default(prompt_text, default="", hide_input=False, required=Fals
91120
@click.option("--force", is_flag=True, help="Force reinstall if server is already installed")
92121
@click.option("--alias", help="Alias for the server", required=False)
93122
@click.help_option("-h", "--help")
94-
def add(server_name, force=False, alias=None):
123+
def install(server_name, force=False, alias=None):
95124
"""Install an MCP server to the global configuration.
96125
97126
Installs servers to the global MCPM configuration where they can be
@@ -100,22 +129,14 @@ def add(server_name, force=False, alias=None):
100129
Examples:
101130
102131
\b
103-
mcpm add time
104-
mcpm add everything --force
105-
mcpm add youtube --alias yt
132+
mcpm install time
133+
mcpm install everything --force
134+
mcpm install youtube --alias yt
106135
"""
107136

108-
# v2.0: use global config
109-
110-
# Check if this is a profile (starts with %)
111-
if server_name.startswith("%"):
112-
profile_name = server_name[1:] # Remove % prefix
113-
add_profile_to_client(profile_name, "global", alias, force)
114-
return
115-
116137
config_name = alias or server_name
117138

118-
# v2.0: All servers are installed to global configuration
139+
# All servers are installed to global configuration
119140
console.print("[yellow]Installing server to global configuration...[/]")
120141

121142
# Get server metadata from repository
@@ -240,12 +261,30 @@ def add(server_name, force=False, alias=None):
240261
# Process variables to store in config
241262
processed_variables = {}
242263

243-
# First, prompt for all defined arguments even if they're not in env_vars
244-
progress.stop()
264+
# Extract which arguments are actually referenced in the selected installation method
265+
referenced_vars = _extract_referenced_variables(selected_method) if selected_method else set()
266+
267+
# Filter arguments to only those that are referenced
268+
relevant_arguments = {}
245269
if all_arguments:
270+
if referenced_vars:
271+
# Only include arguments that are referenced
272+
for arg_name, arg_info in all_arguments.items():
273+
if arg_name in referenced_vars:
274+
relevant_arguments[arg_name] = arg_info
275+
elif selected_method:
276+
# If we have a selected method but no referenced vars, don't prompt for any
277+
relevant_arguments = {}
278+
else:
279+
# No selected method - use all arguments (backward compatibility)
280+
relevant_arguments = all_arguments
281+
282+
# First, prompt for relevant arguments
283+
progress.stop()
284+
if relevant_arguments:
246285
console.print("\n[bold]Configure server arguments:[/]")
247286

248-
for arg_name, arg_info in all_arguments.items():
287+
for arg_name, arg_info in relevant_arguments.items():
249288
description = arg_info.get("description", "")
250289
is_required = arg_info.get("required", False)
251290
example = arg_info.get("example", "")
@@ -328,6 +367,21 @@ def add(server_name, force=False, alias=None):
328367
if key in processed_variables and replacement_status == ReplacementStatus.NON_STANDARD_REPLACE:
329368
has_non_standard_argument_define = True
330369

370+
# For HTTP servers, headers should be extracted from the installation method
371+
# not from processed variables
372+
processed_headers = {}
373+
if installation_method == "http" and selected_method:
374+
# Extract headers from the installation method if defined
375+
headers_template = selected_method.get("headers", {})
376+
for key, value in headers_template.items():
377+
# Replace variables in header values
378+
header_replaced, _ = _replace_variables(value, processed_variables)
379+
if header_replaced:
380+
processed_headers[key] = header_replaced
381+
else:
382+
# If no replacement, use the original value
383+
processed_headers[key] = value
384+
331385
if has_non_standard_argument_define:
332386
# no matter in argument / env
333387
console.print(
@@ -338,11 +392,22 @@ def add(server_name, force=False, alias=None):
338392

339393
# Get actual MCP execution command, args, and env from the selected installation method
340394
# This ensures we use the actual server command information instead of placeholders
395+
mcp_url = None
396+
mcp_command = None
397+
mcp_args = []
398+
341399
if selected_method:
342-
mcp_command = selected_method.get("command", install_command)
343-
mcp_args = processed_args
400+
# For HTTP servers, extract the URL and don't set command/args
401+
if installation_method == "http":
402+
mcp_url = selected_method.get("url")
403+
# HTTP servers don't have command/args
404+
else:
405+
# For non-HTTP servers, get command and args
406+
mcp_command = selected_method.get("command", install_command)
407+
mcp_args = processed_args
344408
# Env vars are already processed above
345409
else:
410+
# Fallback for when no selected method
346411
mcp_command = install_command
347412
mcp_args = processed_args
348413

@@ -356,9 +421,11 @@ def add(server_name, force=False, alias=None):
356421
env=processed_env,
357422
# Use the simplified installation method
358423
installation=installation_method,
424+
url=mcp_url, # Include URL for HTTP servers
425+
headers=processed_headers, # Include headers for HTTP servers
359426
)
360427

361-
# v2.0: Add server to global configuration
428+
# Add server to global configuration
362429
success = global_add_server(full_server_config.to_server_config(), force)
363430

364431
if success:
@@ -393,6 +460,41 @@ def _should_hide_input(arg_name: str) -> bool:
393460
return "token" in arg_name.lower() or "key" in arg_name.lower() or "secret" in arg_name.lower()
394461

395462

463+
def _extract_referenced_variables(installation_method: dict) -> set:
464+
"""Extract all variable names referenced in an installation method.
465+
466+
Scans through all fields in the installation method (command, args, env, url, etc.)
467+
looking for ${VARIABLE_NAME} patterns.
468+
469+
Args:
470+
installation_method: The installation method configuration dict
471+
472+
Returns:
473+
Set of variable names that are referenced
474+
"""
475+
referenced = set()
476+
477+
def extract_from_value(value):
478+
"""Recursively extract variables from a value."""
479+
if isinstance(value, str):
480+
# Find all ${VAR_NAME} patterns
481+
matches = re.findall(r"\$\{([^}]+)\}", value)
482+
referenced.update(matches)
483+
elif isinstance(value, list):
484+
for item in value:
485+
extract_from_value(item)
486+
elif isinstance(value, dict):
487+
for v in value.values():
488+
extract_from_value(v)
489+
490+
# Check all fields in the installation method
491+
for key, value in installation_method.items():
492+
if key not in ["type", "description", "recommended"]: # Skip metadata fields
493+
extract_from_value(value)
494+
495+
return referenced
496+
497+
396498
class ReplacementStatus(str, Enum):
397499
NOT_REPLACED = "not_replaced"
398500
STANDARD_REPLACE = "standard_replace"
@@ -473,18 +575,3 @@ def _replace_argument_variables(value: str, prev_value: str, variables: dict) ->
473575

474576
# nothing to replace
475577
return value, ReplacementStatus.NOT_REPLACED
476-
477-
478-
def add_profile_to_client(profile_name: str, client: str, alias: str | None = None, force: bool = False):
479-
if not force and not Confirm.ask(f"Add this profile {profile_name} to {client}{' as ' + alias if alias else ''}?"):
480-
console.print("[yellow]Operation cancelled.[/]")
481-
raise click.ClickException("Operation cancelled")
482-
483-
console.print("[bold red]Error:[/] Profile activation has been removed in MCPM v2.0.")
484-
console.print("[yellow]Use 'mcpm profile share' to share profiles instead.[/]")
485-
success = False
486-
if success:
487-
console.print(f"[bold green]Successfully added profile {profile_name} to {client}![/]")
488-
else:
489-
console.print(f"[bold red]Failed to add profile {profile_name} to {client}.[/]")
490-
raise click.ClickException(f"Failed to add profile {profile_name} to {client}")

src/mcpm/commands/list.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44

55
from rich.console import Console
66

7-
from mcpm.commands.target_operations.common import global_list_servers
7+
from mcpm.global_config import GlobalConfigManager
88
from mcpm.profile.profile_config import ProfileConfigManager
99
from mcpm.utils.display import print_server_config
1010
from mcpm.utils.rich_click_config import click
1111

1212
console = Console()
1313
profile_manager = ProfileConfigManager()
14+
global_config_manager = GlobalConfigManager()
15+
16+
17+
def global_list_servers():
18+
"""List all servers in the global MCPM configuration."""
19+
return global_config_manager.list_servers()
1420

1521

1622
@click.command()

src/mcpm/commands/run.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,17 @@ def run(server_name, http, port):
151151
# Debug logging is now handled by the Rich logging setup in CLI
152152
# Just log debug info - the level is controlled centrally
153153
logger.debug(f"Running server '{server_name}' from {location} configuration")
154-
logger.debug(f"Command: {server_config.command} {' '.join(server_config.args or [])}")
154+
155+
# Log command details based on server type
156+
from mcpm.core.schema import RemoteServerConfig, STDIOServerConfig
157+
158+
if isinstance(server_config, STDIOServerConfig):
159+
logger.debug(f"Command: {server_config.command} {' '.join(server_config.args or [])}")
160+
elif isinstance(server_config, RemoteServerConfig):
161+
logger.debug(f"URL: {server_config.url}")
162+
if server_config.headers:
163+
logger.debug(f"Headers: {list(server_config.headers.keys())}")
164+
155165
logger.debug(f"Mode: {'HTTP' if http else 'stdio'}")
156166
if http:
157167
logger.debug(f"Port: {port}")

0 commit comments

Comments
 (0)