Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ local/

# node
node_modules/
.pre-commit-config.yaml
6 changes: 3 additions & 3 deletions mcp-registry/servers/firecrawl.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"description": "Advanced web scraping with JavaScript rendering, PDF support, and smart rate limiting",
"repository": {
"type": "git",
"url": "https://github.com/mendableai/firecrawl-mcp-server"
"url": "https://github.com/firecrawl/firecrawl-mcp-server"
},
"homepage": "https://github.com/mendableai/firecrawl-mcp-server",
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
"author": {
"name": "mendableai"
"name": "firecrawl"
},
"license": "MIT",
"categories": [
Expand Down
9 changes: 4 additions & 5 deletions src/mcpm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""

# Import rich-click configuration before anything else
import os
from pathlib import Path
from typing import Any, Dict

from rich.console import Console
Expand Down Expand Up @@ -30,10 +32,8 @@
from mcpm.commands.share import share
from mcpm.utils.logging_config import setup_logging
from mcpm.utils.rich_click_config import click, get_header_text
import os
from pathlib import Path

console = Console() # stdout for regular CLI output
console = Console() # stdout for regular CLI output
err_console = Console(stderr=True) # stderr for errors/tracebacks
client_config_manager = ClientConfigManager()

Expand Down Expand Up @@ -97,8 +97,7 @@ def main(ctx, version, help_flag):
# like some Electron apps that don't set a valid cwd.
home_dir = str(Path.home())
err_console.print(
f"Current working directory is invalid. Changing to home directory: {home_dir}",
style="bold yellow"
f"Current working directory is invalid. Changing to home directory: {home_dir}", style="bold yellow"
)
os.chdir(home_dir)

Expand Down
13 changes: 13 additions & 0 deletions src/mcpm/clients/client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ def _refresh_config(self):
"""Refresh the local config cache from the config manager"""
self._config = self.config_manager.get_config()

def get_client_path(self, client_name: str) -> str | None:
"""Get the stored configuration path for a client"""
self._refresh_config()
client_paths = self._config.get("client_paths", {})
return client_paths.get(client_name)

def set_client_path(self, client_name: str, path: str) -> bool:
"""Set and persist a custom configuration path for a client"""
self._refresh_config()
client_paths = self._config.get("client_paths", {})
client_paths[client_name] = path
return self.config_manager.set_config("client_paths", client_paths)

def get_supported_clients(self) -> List[str]:
"""Get a list of supported client names"""
# Import here to avoid circular imports
Expand Down
6 changes: 6 additions & 0 deletions src/mcpm/clients/client_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ def get_client_manager(
Returns:
BaseClientManager: Client manager instance or None if not found
"""
# Check if there's a stored custom path if no override provided
if not config_path_override:
stored_path = cls._client_config_manager.get_client_path(client_name)
if stored_path:
config_path_override = stored_path

Comment on lines +71 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use is None if you want to distinguish “unset” from “intentionally empty”.

If callers might pass config_path_override="" intentionally, if not config_path_override: will override it. Safer:

-        if not config_path_override:
+        if config_path_override is None:
             stored_path = cls._client_config_manager.get_client_path(client_name)
             if stored_path:
                 config_path_override = stored_path
🤖 Prompt for AI Agents
In src/mcpm/clients/client_registry.py around lines 71 to 76, the code uses a
falsy check (if not config_path_override) which treats an intentionally empty
string the same as unset; change the condition to explicitly check for None (if
config_path_override is None) so that only the truly unset case triggers
fetching stored_path from _client_config_manager; if stored_path is found,
assign it to config_path_override, otherwise leave the provided value (including
empty string) untouched.

manager_class = cls._CLIENT_MANAGERS.get(client_name)
if manager_class:
return manager_class(config_path_override=config_path_override)
Expand Down
70 changes: 59 additions & 11 deletions src/mcpm/commands/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def client():

\b
mcpm client ls # List all supported MCP clients and their status
mcpm client config cursor --set-path /path/to/settings.json # Set custom config path
mcpm client edit cursor # Interactive server selection for Cursor
mcpm client edit claude-desktop # Interactive server selection for Claude Desktop
mcpm client edit cursor -e # Open Cursor config in external editor
Expand All @@ -47,6 +48,43 @@ def client():
pass


@client.command(name="config", context_settings=dict(help_option_names=["-h", "--help"]))
@click.argument("client_name")
@click.option("--set-path", help="Set a custom configuration file path for the client")
@click.option("--get-path", is_flag=True, help="Get the currently stored configuration path")
@click.option("--clear-path", is_flag=True, help="Clear the stored custom configuration path")
def config_client(client_name, set_path, get_path, clear_path):
"""Configure client settings (e.g., custom config paths)."""
if not any([set_path, get_path, clear_path]):
console.print("[yellow]No action specified. Use --set-path, --get-path, or --clear-path.[/]")
return

# Check if client is supported
supported_clients = ClientRegistry.get_supported_clients()
if client_name not in supported_clients:
console.print(f"[red]Error: Client '{client_name}' is not supported.[/]")
return

if set_path:
if client_config_manager.set_client_path(client_name, set_path):
console.print(f"[green]Successfully set custom config path for {client_name}:[/] {set_path}")
else:
console.print(f"[red]Failed to set config path for {client_name}[/]")

if clear_path:
if client_config_manager.set_client_path(client_name, None):
console.print(f"[green]Successfully cleared custom config path for {client_name}[/]")
else:
console.print(f"[red]Failed to clear config path for {client_name}[/]")

if get_path or set_path or clear_path: # Always show current path after modification or if requested
current_path = client_config_manager.get_client_path(client_name)
if current_path:
console.print(f"[bold]Current stored path for {client_name}:[/] {current_path}")
else:
console.print(f"[dim]No custom path stored for {client_name} (using default)[/]")

Comment on lines +51 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

--clear-path likely doesn’t clear (stores null / violates set_client_path contract).

ClientConfigManager.set_client_path(self, client_name: str, path: str) -> bool (per provided snippet) doesn’t accept None, and even if it “works”, it likely persists a null entry instead of removing it.

Also, --set-path/--get-path/--clear-path can be combined with ambiguous results (e.g., set+clear).

Suggested shape:

  • Make set_client_path accept Optional[str] and delete the key when None, or add an explicit clear_client_path(client_name) API and call that here.
  • Enforce exactly one action flag per invocation.
 def config_client(client_name, set_path, get_path, clear_path):
     """Configure client settings (e.g., custom config paths)."""
-    if not any([set_path, get_path, clear_path]):
+    actions = sum(bool(x) for x in [set_path, get_path, clear_path])
+    if actions == 0:
         console.print("[yellow]No action specified. Use --set-path, --get-path, or --clear-path.[/]")
         return
+    if actions > 1:
+        console.print("[red]Error: choose only one of --set-path, --get-path, or --clear-path.[/]")
+        return
@@
     if set_path:
+        set_path = os.path.abspath(os.path.expanduser(set_path))
         if client_config_manager.set_client_path(client_name, set_path):
             console.print(f"[green]Successfully set custom config path for {client_name}:[/] {set_path}")
@@
     if clear_path:
-        if client_config_manager.set_client_path(client_name, None):
+        # Prefer: client_config_manager.clear_client_path(client_name)
+        if client_config_manager.set_client_path(client_name, None):
             console.print(f"[green]Successfully cleared custom config path for {client_name}[/]")

(You’d still need the ClientConfigManager change to make the None call correct.)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/mcpm/commands/client.py around lines 51 to 86, the CLI currently allows
combining --set-path/--get-path/--clear-path and calls
set_client_path(client_name, None) to clear which violates the existing
set_client_path signature and may persist a null entry; enforce exactly one
action flag per invocation (error out if zero or more than one flags are
provided), and replace the call that attempts to clear by invoking an explicit
clear_client_path(client_name) method (or, if you choose to change
ClientConfigManager instead, update set_client_path to accept Optional[str] and
delete the stored key when path is None and then call it here). Ensure the code
validates flags first, then performs one of: set_client_path(client_name,
set_path), clear_client_path(client_name) (or set_client_path(client_name, None)
only after updating its signature/behavior), or get_client_path(client_name),
and only show the current path after the single successful action.


@client.command(name="ls", context_settings=dict(help_option_names=["-h", "--help"]))
@click.option("--verbose", "-v", is_flag=True, help="Show detailed server information")
def list_clients(verbose):
Expand Down Expand Up @@ -226,7 +264,18 @@ def list_clients(verbose):
@click.option("--remove-profile", help="Comma-separated list of profile names to remove")
@click.option("--set-profiles", help="Comma-separated list of profile names to set (replaces all)")
@click.option("--force", is_flag=True, help="Skip confirmation prompts")
def edit_client(client_name, external, config_path_override, add_server, remove_server, set_servers, add_profile, remove_profile, set_profiles, force):
def edit_client(
client_name,
external,
config_path_override,
add_server,
remove_server,
set_servers,
add_profile,
remove_profile,
set_profiles,
force,
):
"""Enable/disable MCPM-managed servers in the specified client configuration.

Interactive by default, or use CLI parameters for automation.
Expand Down Expand Up @@ -1178,6 +1227,7 @@ def _edit_client_non_interactive(
return 1

from mcpm.profile.profile_config import ProfileConfigManager

profile_manager = ProfileConfigManager()
available_profiles = profile_manager.list_profiles()

Expand Down Expand Up @@ -1261,7 +1311,9 @@ def _edit_client_non_interactive(

# Show profile changes
if final_profiles != set(current_profiles):
console.print(f"Profiles: [dim]{len(current_profiles)} profiles[/] → [cyan]{len(final_profiles)} profiles[/]")
console.print(
f"Profiles: [dim]{len(current_profiles)} profiles[/] → [cyan]{len(final_profiles)} profiles[/]"
)

added_profiles = final_profiles - set(current_profiles)
if added_profiles:
Expand All @@ -1275,7 +1327,9 @@ def _edit_client_non_interactive(

# Show server changes
if final_servers != set(current_individual_servers):
console.print(f"Servers: [dim]{len(current_individual_servers)} servers[/] → [cyan]{len(final_servers)} servers[/]")
console.print(
f"Servers: [dim]{len(current_individual_servers)} servers[/] → [cyan]{len(final_servers)} servers[/]"
)

added_servers = final_servers - set(current_individual_servers)
if added_servers:
Expand Down Expand Up @@ -1310,9 +1364,7 @@ def _edit_client_non_interactive(
try:
profile_server_name = f"mcpm_profile_{profile_name}"
server_config = STDIOServerConfig(
name=profile_server_name,
command="mcpm",
args=["profile", "run", profile_name]
name=profile_server_name, command="mcpm", args=["profile", "run", profile_name]
)
client_manager.add_server(server_config)
except Exception as e:
Expand All @@ -1331,11 +1383,7 @@ def _edit_client_non_interactive(
for server_name in final_servers - set(current_individual_servers):
try:
prefixed_name = f"mcpm_{server_name}"
server_config = STDIOServerConfig(
name=prefixed_name,
command="mcpm",
args=["run", server_name]
)
server_config = STDIOServerConfig(name=prefixed_name, command="mcpm", args=["run", server_name])
client_manager.add_server(server_config)
except Exception as e:
console.print(f"[red]Error adding server {server_name}: {e}[/]")
Expand Down
22 changes: 21 additions & 1 deletion src/mcpm/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,27 @@ def doctor():
console.print(f" ❌ Profile check error: {e}")
issues_found += 1

# 8. Summary
# 8. Check Daemon Status
console.print("[bold cyan]🤖 Daemon Status[/]")
try:
import urllib.request

try:
with urllib.request.urlopen("http://localhost:6276/health", timeout=2) as response:
if response.status == 200:
console.print(" ✅ MCPM Daemon is running (port 6276)")
else:
console.print(f" ⚠️ MCPM Daemon responded with status {response.status}")
issues_found += 1
except Exception as e:
console.print(f" ❌ MCPM Daemon not reachable: {e}")
console.print(" (Run 'mcpm-daemon' or 'docker compose up' to start it)")
issues_found += 1
except Exception as e:
console.print(f" ❌ Error checking daemon: {e}")
issues_found += 1

Comment on lines +173 to +192
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix non-200 handling: urlopen() raises HTTPError, so the response.status != 200 branch likely won’t run.

Right now, a non-200 health response will typically be reported as “not reachable” (via the broad exception) instead of “responded with status X”, which makes the diagnostic misleading.

     # 8. Check Daemon Status
     console.print("[bold cyan]🤖 Daemon Status[/]")
     try:
         import urllib.request
+        import urllib.error
 
         try:
             with urllib.request.urlopen("http://localhost:6276/health", timeout=2) as response:
                 if response.status == 200:
                     console.print("  ✅ MCPM Daemon is running (port 6276)")
                 else:
                     console.print(f"  ⚠️  MCPM Daemon responded with status {response.status}")
                     issues_found += 1
-        except Exception as e:
-            console.print(f"  ❌ MCPM Daemon not reachable: {e}")
+        except urllib.error.HTTPError as e:
+            console.print(f"  ⚠️  MCPM Daemon responded with status {e.code}")
+            issues_found += 1
+        except (urllib.error.URLError, TimeoutError) as e:
+            console.print(f"  ❌ MCPM Daemon not reachable: {e}")
             console.print("     (Run 'mcpm-daemon' or 'docker compose up' to start it)")
             issues_found += 1
     except Exception as e:
         console.print(f"  ❌ Error checking daemon: {e}")
         issues_found += 1

Optional: consider whether “daemon not running” should always increment issues_found (vs. only when daemon mode is configured/enabled).

🤖 Prompt for AI Agents
In src/mcpm/commands/doctor.py around lines 173 to 192, urllib.request.urlopen
raises urllib.error.HTTPError for non-200 responses so the current broad except
hides status codes; change the try/except to explicitly catch HTTPError and
URLError (or import urllib.error.HTTPError and URLError), on HTTPError read the
.code and print "MCPM Daemon responded with status X" and increment
issues_found, on URLError or other exceptions print "Daemon not reachable" and
increment issues_found; keep success branch for a 200 response unchanged.

# 9. Summary
console.print()
if issues_found == 0:
console.print("[bold green]✅ All systems healthy! No issues found.[/]")
Expand Down
62 changes: 57 additions & 5 deletions src/mcpm/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import shutil
from enum import Enum

from prompt_toolkit import PromptSession
Expand All @@ -20,6 +21,7 @@
from mcpm.profile.profile_config import ProfileConfigManager
from mcpm.schemas.full_server_config import FullServerConfig
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
from mcpm.utils.non_interactive import should_force_operation
from mcpm.utils.repository import RepositoryManager
from mcpm.utils.rich_click_config import click

Expand Down Expand Up @@ -72,18 +74,31 @@ def global_add_server(server_config: ServerConfig, force: bool = False) -> bool:
return global_config_manager.add_server(server_config, force)


def prompt_with_default(prompt_text, default="", hide_input=False, required=False):
def prompt_with_default(prompt_text, default="", hide_input=False, required=False, force=False):
"""Prompt the user with a default value that can be edited directly.

Args:
prompt_text: The prompt text to display
default: The default value to show in the prompt
hide_input: Whether to hide the input (for passwords)
required: Whether this is a required field
force: Whether to force non-interactive mode

Returns:
The user's input or the default value if empty
"""
# Check for explicit non-interactive mode (Env Var)
# We do NOT check is_non_interactive() here because it includes isatty(),
# which returns True in tests (CliRunner), causing us to skip mocked prompts.
# Users desiring non-interactive behavior must set MCPM_NON_INTERACTIVE=true.
if os.getenv("MCPM_NON_INTERACTIVE", "").lower() == "true" or should_force_operation(force):
if default:
return default
if required:
# Cannot fulfill required argument without default in non-interactive mode
raise click.UsageError("A required value has no default and cannot be prompted in non-interactive mode.")
return ""

# if default:
# console.print(f"Default: [yellow]{default}[/]")

Expand Down Expand Up @@ -137,7 +152,8 @@ def install(server_name, force=False, alias=None):
config_name = alias or server_name

# All servers are installed to global configuration
console.print("[yellow]Installing server to global configuration...[/]")
console_stderr = Console(stderr=True)
console_stderr.print("[yellow]Installing server to global configuration...[/]")

# Get server metadata from repository
server_metadata = repo_manager.get_server_metadata(server_name)
Expand All @@ -161,7 +177,9 @@ def install(server_name, force=False, alias=None):

# Confirm addition
alias_text = f" as '{alias}'" if alias else ""
if not force and not Confirm.ask(f"Install this server to global configuration{alias_text}?"):
if not should_force_operation(force) and not Confirm.ask(
f"Install this server to global configuration{alias_text}?"
):
console.print("[yellow]Operation cancelled.[/]")
return

Expand Down Expand Up @@ -206,7 +224,7 @@ def install(server_name, force=False, alias=None):
selected_method = installations[method_id]

# If multiple methods are available and not forced, offer selection
if len(installations) > 1 and not force:
if len(installations) > 1 and not should_force_operation(force):
console.print("\n[bold]Available installation methods:[/]")
methods_list = []

Expand Down Expand Up @@ -411,6 +429,40 @@ def install(server_name, force=False, alias=None):
mcp_command = install_command
mcp_args = processed_args

# --- Auto-UVX Injection Logic ---
# If 'uv' is available and we are using python/pip, try to upgrade to 'uv run' for isolation.
# This solves the "Pydantic Versioning" dependency hell by isolating servers.
if mcp_command in ["python", "python3", "pip"] and shutil.which("uv"):
# We need to determine the package name to run 'uv run --with <package>'
# If package_name was defined in the installation method, use it.
# If not, check if we are running 'python -m <module>' and guess package name from module?
# Or default to the server name if reasonable?
target_package = package_name

# If args start with '-m', the next arg is the module.
# Often module == package (e.g. mcp_server_time -> mcp-server-time? No, dashes vs underscores).
# But 'uv run --with <module> python -m <module>' usually works if PyPI name matches.

if not target_package and mcp_args and mcp_args[0] == "-m" and len(mcp_args) > 1:
# Heuristic: Assume package name matches module name (with _ -> - maybe?)
# Ideally, the registry should provide 'package'.
# For now, we only auto-upgrade if we have a package name OR if we are brave.
# Let's rely on package_name variable extracted earlier from selected_method.get("package")
pass

if target_package:
console.print(f"[bold blue]🚀 Auto-upgrading to 'uv run' for isolation (package: {target_package})[/]")
# Old: python -m module ...
# New: uv run --with package python -m module ...

# We prepend 'run --with package' to the command execution
# mcp_command becomes 'uv'
# mcp_args becomes ['run', '--with', target_package, original_command] + mcp_args

new_args = ["run", "--with", target_package, mcp_command] + mcp_args
mcp_command = "uv"
mcp_args = new_args

# Create server configuration using FullServerConfig
full_server_config = FullServerConfig(
name=config_name,
Expand All @@ -426,7 +478,7 @@ def install(server_name, force=False, alias=None):
)

# Add server to global configuration
success = global_add_server(full_server_config.to_server_config(), force)
success = global_add_server(full_server_config.to_server_config(), should_force_operation(force))

if success:
# Server has been successfully added to the global configuration
Expand Down
Loading