Skip to content

Commit b0a54e8

Browse files
niechenclaude
andcommitted
Add -N/--new and -e/--editor options to edit command
Adds two new arguments to enhance the edit command functionality: - -N/--new: Interactive creation of new server configurations - -e/--editor: Opens global config file in external editor Also fixes help formatting and config path reference bug. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 9233744 commit b0a54e8

File tree

2 files changed

+285
-8
lines changed

2 files changed

+285
-8
lines changed

src/mcpm/commands/edit.py

Lines changed: 251 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Edit command for modifying server configurations
33
"""
44

5+
import os
6+
import subprocess
57
import sys
68
from typing import Any, Dict, Optional
79

@@ -19,8 +21,10 @@
1921

2022

2123
@click.command(name="edit", context_settings=dict(help_option_names=["-h", "--help"]))
22-
@click.argument("server_name")
23-
def edit(server_name):
24+
@click.argument("server_name", required=False)
25+
@click.option("-N", "--new", is_flag=True, help="Create a new server configuration")
26+
@click.option("-e", "--editor", is_flag=True, help="Open global config in external editor")
27+
def edit(server_name, new, editor):
2428
"""Edit a server configuration.
2529
2630
Opens an interactive form editor that allows you to:
@@ -30,15 +34,33 @@ def edit(server_name):
3034
3135
Examples:
3236
33-
\\b
34-
mcpm edit time # Interactive form
37+
mcpm edit time # Edit existing server
3538
mcpm edit agentkit # Edit agentkit server
36-
mcpm edit remote-api # Edit remote server
39+
mcpm edit -N # Create new server
40+
mcpm edit -e # Open global config in editor
3741
"""
42+
# Handle editor mode
43+
if editor:
44+
_open_global_config_in_editor()
45+
return 0
46+
47+
# Handle new server mode
48+
if new:
49+
if server_name:
50+
print_error("Cannot specify both server name and --new flag", "Use either 'mcpm edit <server>' or 'mcpm edit --new'")
51+
raise click.ClickException("Cannot specify both server name and --new flag")
52+
_create_new_server()
53+
return 0
54+
55+
# Require server name for editing existing servers
56+
if not server_name:
57+
print_error("Server name is required", "Use 'mcpm edit <server>', 'mcpm edit --new', or 'mcpm edit --editor'")
58+
raise click.ClickException("Server name is required")
59+
3860
# Get the existing server
3961
server_config = global_config_manager.get_server(server_name)
4062
if not server_config:
41-
print_error(f"Server '{server_name}' not found", "Run 'mcpm ls' to see available servers")
63+
print_error(f"Server '{server_name}' not found", "Run 'mcpm ls' to see available servers or use 'mcpm edit --new' to create one")
4264
raise click.ClickException(f"Server '{server_name}' not found")
4365

4466
# Display current configuration
@@ -287,4 +309,226 @@ def apply_interactive_changes(server_config, interactive_result):
287309
key, value = header_pair.split("=", 1)
288310
server_config.headers[key.strip()] = value.strip()
289311

290-
return True
312+
return True
313+
314+
315+
def _open_global_config_in_editor():
316+
"""Open the global MCPM configuration file in the default editor."""
317+
try:
318+
# Get the global config file path
319+
config_path = global_config_manager.config_path
320+
321+
if not os.path.exists(config_path):
322+
console.print("[yellow]No global configuration file found.[/]")
323+
console.print("[dim]Install a server first with 'mcpm install <server>' to create the config file.[/]")
324+
return
325+
326+
console.print("[bold green]Opening global MCPM configuration in your default editor...[/]")
327+
328+
# Use appropriate command based on platform
329+
if os.name == "nt": # Windows
330+
os.startfile(config_path)
331+
elif os.name == "posix": # macOS and Linux
332+
subprocess.run(["open", config_path] if os.uname().sysname == "Darwin" else ["xdg-open", config_path])
333+
334+
console.print(f"[italic]Global config file: {config_path}[/]")
335+
console.print("[dim]After editing, restart any running MCP servers for changes to take effect.[/]")
336+
except Exception as e:
337+
print_error("Error opening editor", str(e))
338+
console.print(f"You can manually edit the file at: {config_path}")
339+
340+
341+
def _create_new_server():
342+
"""Create a new server configuration interactively."""
343+
console.print("[bold green]Create New Server Configuration[/]")
344+
console.print("[dim]Type your answers, press Enter to confirm each field, ESC to cancel[/]")
345+
console.print()
346+
347+
try:
348+
result = _interactive_new_server_form()
349+
350+
if result is None:
351+
console.print("[yellow]Interactive editing not available in this environment[/]")
352+
console.print("[dim]This command requires a terminal for interactive input[/]")
353+
return 1
354+
355+
if result.get("cancelled", True):
356+
console.print("[yellow]Server creation cancelled[/]")
357+
return 0
358+
359+
# Check if server name already exists
360+
server_name = result["answers"]["name"]
361+
if global_config_manager.get_server(server_name):
362+
console.print(f"[red]Error: Server '[bold]{server_name}[/]' already exists[/]")
363+
return 1
364+
365+
# Create the server config based on type
366+
server_type = result["answers"]["type"]
367+
if server_type == "stdio":
368+
server_config = STDIOServerConfig(
369+
name=server_name,
370+
command=result["answers"]["command"],
371+
args=[arg.strip() for arg in result["answers"]["args"].split(",") if arg.strip()] if result["answers"]["args"] else [],
372+
env={}
373+
)
374+
375+
# Parse environment variables
376+
if result["answers"]["env"]:
377+
for env_pair in result["answers"]["env"].split(","):
378+
if "=" in env_pair:
379+
key, value = env_pair.split("=", 1)
380+
server_config.env[key.strip()] = value.strip()
381+
else: # remote
382+
server_config = RemoteServerConfig(
383+
name=server_name,
384+
url=result["answers"]["url"],
385+
headers={}
386+
)
387+
388+
# Parse headers
389+
if result["answers"]["headers"]:
390+
for header_pair in result["answers"]["headers"].split(","):
391+
if "=" in header_pair:
392+
key, value = header_pair.split("=", 1)
393+
server_config.headers[key.strip()] = value.strip()
394+
395+
# Save the new server
396+
try:
397+
global_config_manager.add_server(server_config)
398+
console.print(f"[green]✅ Successfully created server '[cyan]{server_name}[/]'[/]")
399+
except Exception as e:
400+
print_error("Failed to save new server", str(e))
401+
raise click.ClickException(f"Failed to save new server: {e}")
402+
403+
return 0
404+
405+
except Exception as e:
406+
console.print(f"[red]Error creating new server: {e}[/]")
407+
return 1
408+
409+
410+
def _interactive_new_server_form() -> Optional[Dict[str, Any]]:
411+
"""Interactive form for creating a new server."""
412+
# Check if we're in a terminal that supports interactive input
413+
if not sys.stdin.isatty():
414+
return None
415+
416+
try:
417+
# Clear any remaining command line arguments to avoid conflicts
418+
original_argv = sys.argv[:]
419+
sys.argv = [sys.argv[0]] # Keep only script name
420+
421+
try:
422+
answers = {}
423+
424+
# Server name - required
425+
answers["name"] = inquirer.text(
426+
message="Server name:",
427+
validate=lambda text: len(text.strip()) > 0 and not text.strip() != text.strip(),
428+
invalid_message="Server name cannot be empty or contain leading/trailing spaces",
429+
keybindings={"interrupt": [{"key": "escape"}]},
430+
).execute()
431+
432+
# Server type
433+
answers["type"] = inquirer.select(
434+
message="Server type:",
435+
choices=[
436+
{"name": "STDIO Server (local command)", "value": "stdio"},
437+
{"name": "Remote Server (HTTP/SSE)", "value": "remote"},
438+
],
439+
keybindings={"interrupt": [{"key": "escape"}]},
440+
).execute()
441+
442+
if answers["type"] == "stdio":
443+
# STDIO Server configuration
444+
console.print("\n[cyan]STDIO Server Configuration[/]")
445+
446+
answers["command"] = inquirer.text(
447+
message="Command to execute:",
448+
validate=lambda text: len(text.strip()) > 0,
449+
invalid_message="Command cannot be empty",
450+
keybindings={"interrupt": [{"key": "escape"}]},
451+
).execute()
452+
453+
answers["args"] = inquirer.text(
454+
message="Arguments (comma-separated):",
455+
instruction="(Leave empty for no arguments)",
456+
keybindings={"interrupt": [{"key": "escape"}]},
457+
).execute()
458+
459+
answers["env"] = inquirer.text(
460+
message="Environment variables (KEY=value,KEY2=value2):",
461+
instruction="(Leave empty for no environment variables)",
462+
keybindings={"interrupt": [{"key": "escape"}]},
463+
).execute()
464+
465+
else: # remote
466+
# Remote Server configuration
467+
console.print("\n[cyan]Remote Server Configuration[/]")
468+
469+
answers["url"] = inquirer.text(
470+
message="Server URL:",
471+
validate=lambda text: text.strip().startswith(("http://", "https://")) if text.strip() else False,
472+
invalid_message="URL must start with http:// or https://",
473+
keybindings={"interrupt": [{"key": "escape"}]},
474+
).execute()
475+
476+
answers["headers"] = inquirer.text(
477+
message="HTTP headers (KEY=value,KEY2=value2):",
478+
instruction="(Leave empty for no custom headers)",
479+
keybindings={"interrupt": [{"key": "escape"}]},
480+
).execute()
481+
482+
# Confirmation
483+
console.print("\n[bold]Summary of new server:[/]")
484+
console.print(f"Name: [cyan]{answers['name']}[/]")
485+
console.print(f"Type: [cyan]{answers['type'].upper()}[/]")
486+
487+
if answers["type"] == "stdio":
488+
console.print(f"Command: [cyan]{answers['command']}[/]")
489+
new_args = [arg.strip() for arg in answers['args'].split(",") if arg.strip()] if answers['args'] else []
490+
console.print(f"Arguments: [cyan]{new_args}[/]")
491+
492+
new_env = {}
493+
if answers['env']:
494+
for env_pair in answers['env'].split(","):
495+
if "=" in env_pair:
496+
key, value = env_pair.split("=", 1)
497+
new_env[key.strip()] = value.strip()
498+
console.print(f"Environment: [cyan]{new_env}[/]")
499+
500+
else: # remote
501+
console.print(f"URL: [cyan]{answers['url']}[/]")
502+
503+
new_headers = {}
504+
if answers['headers']:
505+
for header_pair in answers['headers'].split(","):
506+
if "=" in header_pair:
507+
key, value = header_pair.split("=", 1)
508+
new_headers[key.strip()] = value.strip()
509+
console.print(f"Headers: [cyan]{new_headers}[/]")
510+
511+
confirm = inquirer.confirm(
512+
message="Create this server?",
513+
default=True,
514+
keybindings={"interrupt": [{"key": "escape"}]},
515+
).execute()
516+
517+
if not confirm:
518+
return {"cancelled": True}
519+
520+
finally:
521+
# Restore original argv
522+
sys.argv = original_argv
523+
524+
return {
525+
"cancelled": False,
526+
"answers": answers,
527+
}
528+
529+
except (KeyboardInterrupt, EOFError):
530+
console.print("\n[yellow]Operation cancelled[/]")
531+
return {"cancelled": True}
532+
except Exception as e:
533+
console.print(f"[red]Error running interactive form: {e}[/]")
534+
return None

tests/test_edit.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,37 @@ def test_edit_command_help():
6060
assert "Edit a server configuration" in result.output
6161
assert "Opens an interactive form editor" in result.output
6262
assert "mcpm edit time" in result.output
63-
assert "Interactive form" in result.output
63+
assert "mcpm edit -N" in result.output
64+
assert "mcpm edit -e" in result.output
65+
assert "--new" in result.output
66+
assert "--editor" in result.output
67+
68+
69+
def test_edit_editor_flag(monkeypatch):
70+
"""Test the -e/--editor flag."""
71+
mock_global_config = Mock()
72+
mock_global_config.config_path = "/tmp/test_servers.json"
73+
monkeypatch.setattr("mcpm.commands.edit.global_config_manager", mock_global_config)
74+
75+
# Mock os.path.exists to return True
76+
monkeypatch.setattr("os.path.exists", lambda path: True)
77+
78+
# Mock subprocess.run to avoid actually opening an editor
79+
mock_subprocess = Mock()
80+
monkeypatch.setattr("subprocess.run", mock_subprocess)
81+
82+
# Mock os.uname to simulate macOS
83+
mock_uname = Mock()
84+
mock_uname.sysname = "Darwin"
85+
monkeypatch.setattr("os.uname", lambda: mock_uname)
86+
87+
runner = CliRunner()
88+
result = runner.invoke(edit, ["-e"])
89+
90+
assert result.exit_code == 0
91+
assert "Opening global MCPM configuration in your default editor" in result.output
92+
assert "/tmp/test_servers.json" in result.output
93+
assert "After editing, restart any running MCP servers" in result.output
94+
95+
# Verify subprocess.run was called with correct arguments
96+
mock_subprocess.assert_called_once_with(["open", "/tmp/test_servers.json"])

0 commit comments

Comments
 (0)