From 0392843ab38f2db1f513101a9bf7697a6e1a85da Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:23:27 +0000 Subject: [PATCH 01/10] add install command to CLI --- src/mcp_agent/cli/commands/__init__.py | 2 + src/mcp_agent/cli/commands/install.py | 400 +++++++++++++++++++ src/mcp_agent/cli/main.py | 4 + tests/cli/commands/test_install.py | 521 +++++++++++++++++++++++++ 4 files changed, 927 insertions(+) create mode 100644 src/mcp_agent/cli/commands/install.py create mode 100644 tests/cli/commands/test_install.py diff --git a/src/mcp_agent/cli/commands/__init__.py b/src/mcp_agent/cli/commands/__init__.py index d50f931c9..1c81d0fad 100644 --- a/src/mcp_agent/cli/commands/__init__.py +++ b/src/mcp_agent/cli/commands/__init__.py @@ -22,6 +22,7 @@ configure, go, check, + install, ) # noqa: F401 __all__ = [ @@ -41,4 +42,5 @@ "configure", "go", "check", + "install", ] diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py new file mode 100644 index 000000000..5234ce234 --- /dev/null +++ b/src/mcp_agent/cli/commands/install.py @@ -0,0 +1,400 @@ +""" +Install command for adding MCP servers to client applications. + +Similar to fastmcp install, this command provides end-to-end installation: +1. Authenticates with MCP Agent Cloud +2. Configures server with required secrets +3. Writes server configuration to client config file + +Supported clients: + - vscode: writes .vscode/mcp.json in project + - claude_code: writes ~/.claude/claude_code_config.json + - cursor: writes ~/.cursor/mcp.json + - claude_desktop: writes ~/.claude/mcp.json (Claude Desktop) + - chatgpt: prints configuration instructions +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, Union + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn + +from mcp_agent.cli.auth import load_api_key_credentials +from mcp_agent.cli.config import settings +from mcp_agent.cli.core.api_client import UnauthenticatedError +from mcp_agent.cli.core.constants import ( + DEFAULT_API_BASE_URL, + ENV_API_BASE_URL, + ENV_API_KEY, + MCP_CONFIGURED_SECRETS_FILENAME, +) +from mcp_agent.cli.core.utils import run_async +from mcp_agent.cli.exceptions import CLIError +from mcp_agent.cli.mcp_app.api_client import MCPAppClient +from mcp_agent.cli.mcp_app.mock_client import MockMCPAppClient +from mcp_agent.cli.secrets.mock_client import MockSecretsClient +from mcp_agent.cli.secrets.processor import configure_user_secrets +from mcp_agent.cli.utils.ux import ( + console, + print_configuration_header, + print_info, + print_success, + print_warning, +) + +app = typer.Typer(help="Install MCP server to client applications") + + +CLIENT_CONFIGS = { + "vscode": { + "path": lambda: Path.cwd() / ".vscode" / "mcp.json", + "description": "VSCode (project-local)", + }, + "claude_code": { + "path": lambda: Path.home() / ".claude" / "claude_code_config.json", + "description": "Claude Code", + }, + "cursor": { + "path": lambda: Path.home() / ".cursor" / "mcp.json", + "description": "Cursor", + }, + "claude_desktop": { + "path": lambda: Path.home() / ".claude" / "mcp.json", + "description": "Claude Desktop", + }, +} + + +def _merge_mcp_json(existing: dict, server_name: str, server_config: dict) -> dict: + """ + Merge a server configuration into existing MCP JSON. + Accepts various formats and always emits {"mcp":{"servers":{...}}}. + """ + servers: dict = {} + if isinstance(existing, dict): + if "mcp" in existing and isinstance(existing.get("mcp"), dict): + servers = dict(existing["mcp"].get("servers") or {}) + elif "servers" in existing and isinstance(existing.get("servers"), dict): + servers = dict(existing.get("servers") or {}) + else: + for k, v in existing.items(): + if isinstance(v, dict) and ("url" in v or "transport" in v): + servers[k] = v + + servers[server_name] = server_config + return {"mcp": {"servers": servers}} + + +def _write_json(path: Path, data: dict) -> None: + """Write JSON data to file, creating parent directories as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _build_server_config(server_url: str, transport: str = "http") -> dict: + """Build server configuration dictionary.""" + return { + "url": server_url, + "transport": transport, + } + + +def _generate_server_name(server_url: str) -> str: + """Generate a server name from URL.""" + # Extract meaningful part from URL + # e.g., https://api.example.com/servers/my-server/mcp -> my-server + parts = server_url.rstrip("/").split("/") + + # If URL has path segments (more than protocol://domain) + if len(parts) > 3: # ['https:', '', 'domain', 'path', ...] + # Try to get the second-to-last meaningful part + # Skip common MCP path segments + path_parts = [p for p in parts[3:] if p and p not in ('mcp', 'sse')] + if path_parts: + return path_parts[-1] + + # Fall back to domain name + if len(parts) >= 3: + domain = parts[2] + domain = domain.split(':')[0] + return domain + + return "server" + + +@app.callback(invoke_without_command=True) +def install( + server_identifier: str = typer.Argument( + ..., help="Server URL, app ID, or app name to install" + ), + client: str = typer.Option( + ..., "--client", "-c", help="Client to install to: vscode|claude_code|cursor|claude_desktop|chatgpt" + ), + name: Optional[str] = typer.Option( + None, "--name", "-n", help="Server name in client config (auto-generated if not provided)" + ), + secrets_file: Optional[Path] = typer.Option( + None, + "--secrets-file", + "-s", + help="Path to secrets.yaml file with user secret IDs. If not provided, secrets will be prompted interactively.", + exists=True, + readable=True, + dir_okay=False, + resolve_path=True, + ), + secrets_output_file: Optional[Path] = typer.Option( + None, + "--secrets-output-file", + "-o", + help="Path to write configured secrets. Defaults to mcp_agent.configured.secrets.yaml", + resolve_path=True, + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Validate configuration but don't install" + ), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite existing server configuration" + ), + api_url: Optional[str] = typer.Option( + settings.API_BASE_URL, + "--api-url", + help="API base URL", + envvar=ENV_API_BASE_URL, + ), + api_key: Optional[str] = typer.Option( + settings.API_KEY, + "--api-key", + help="API key for authentication", + envvar=ENV_API_KEY, + ), +) -> None: + """ + Install an MCP server to a client application. + + This command: + 1. Authenticates with MCP Agent Cloud + 2. Configures the server with required secrets + 3. Writes the server configuration to the client's config file + + Examples: + # Install to VSCode + mcp-agent install https://api.example.com/servers/my-server/mcp --client vscode + + # Install to Claude Code with custom name + mcp-agent install app-id-123 --client claude_code --name my-server + + # Install with existing secrets file + mcp-agent install my-server --client cursor --secrets-file secrets.yaml + """ + client_lc = client.lower() + + if client_lc not in CLIENT_CONFIGS and client_lc != "chatgpt": + raise CLIError( + f"Unsupported client: {client}. Supported clients: vscode, claude_code, cursor, claude_desktop, chatgpt" + ) + + effective_api_key = api_key or settings.API_KEY or load_api_key_credentials() + if not effective_api_key: + raise CLIError( + "Must be logged in to install. Run 'mcp-agent login', set MCP_API_KEY environment variable, or specify --api-key option." + ) + + mcp_client: Union[MockMCPAppClient, MCPAppClient] + if dry_run: + print_info("Using MOCK API client for dry run") + mcp_client = MockMCPAppClient( + api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key + ) + else: + mcp_client = MCPAppClient( + api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key + ) + + if secrets_file and secrets_output_file: + raise CLIError( + "Cannot provide both --secrets-file and --secrets-output-file. Please specify only one." + ) + if secrets_file and not secrets_file.suffix == ".yaml": + raise CLIError( + "The --secrets-file must be a YAML file. Please provide a valid path." + ) + if secrets_output_file and not secrets_output_file.suffix == ".yaml": + raise CLIError( + "The --secrets-output-file must be a YAML file. Please provide a valid path." + ) + + # Normalize server identifier to URL + app_server_url = server_identifier + if not server_identifier.startswith("http://") and not server_identifier.startswith("https://"): + # Could be app ID or name - try to resolve via API + # For now, treat as URL and let API handle validation + # In future, could add app lookup by ID/name + print_warning( + f"Treating '{server_identifier}' as server URL. If this is an app ID/name, URL resolution is not yet implemented." + ) + + console.print(f"\n[bold cyan]Installing MCP Server[/bold cyan]\n") + print_info(f"Server: {app_server_url}") + print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") + + required_params = [] + try: + with Progress( + SpinnerColumn(spinner_name="arrow3"), + TextColumn("[progress.description]{task.description}"), + ) as progress: + task = progress.add_task("Checking server requirements...", total=None) + required_params = run_async( + mcp_client.list_config_params(app_server_url=app_server_url) + ) + progress.update(task, description="✅ Server requirements checked") + except UnauthenticatedError as e: + raise CLIError( + "Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable." + ) from e + except Exception as e: + raise CLIError( + f"Failed to retrieve server requirements: {e}" + ) from e + + configured_secrets = {} + requires_secrets = len(required_params) > 0 + + if requires_secrets: + if not secrets_file and secrets_output_file is None: + secrets_output_file = Path(MCP_CONFIGURED_SECRETS_FILENAME) + print_info(f"Using default secrets output: {secrets_output_file}") + + print_configuration_header(secrets_file, secrets_output_file, dry_run) + print_info( + f"Server requires {len(required_params)} secret(s): {', '.join(required_params)}" + ) + + try: + print_info("Processing user secrets...") + + if dry_run: + print_info("Using MOCK Secrets API client for dry run") + mock_secrets_client = MockSecretsClient( + api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key + ) + configured_secrets = run_async( + configure_user_secrets( + required_secrets=required_params, + config_path=secrets_file, + output_path=secrets_output_file, + client=mock_secrets_client, + ) + ) + else: + configured_secrets = run_async( + configure_user_secrets( + required_secrets=required_params, + config_path=secrets_file, + output_path=secrets_output_file, + api_url=api_url, + api_key=effective_api_key, + ) + ) + + print_success("User secrets processed successfully") + + except Exception as e: + if settings.VERBOSE: + import traceback + typer.echo(traceback.format_exc()) + raise CLIError(f"Failed to process secrets: {e}") from e + else: + print_info("Server does not require any secrets") + if secrets_file: + raise CLIError( + f"Server does not require secrets, but a secrets file was provided: {secrets_file}" + ) + + if dry_run: + print_success("Installation completed in dry run mode (no files written)") + return + + configured_server_url = app_server_url + try: + with Progress( + SpinnerColumn(spinner_name="arrow3"), + TextColumn("[progress.description]{task.description}"), + ) as progress: + task = progress.add_task("Configuring server...", total=None) + config = run_async( + mcp_client.configure_app( + app_server_url=app_server_url, config_params=configured_secrets + ) + ) + progress.update(task, description="✅ Server configured") + + if config.appServerInfo and config.appServerInfo.serverUrl: + configured_server_url = config.appServerInfo.serverUrl + print_info(f"Configured server URL: {configured_server_url}") + + except Exception as e: + raise CLIError(f"Failed to configure server: {e}") from e + + if client_lc == "chatgpt": + console.print( + Panel( + f"[bold]ChatGPT Configuration Instructions[/bold]\n\n" + f"1. Open ChatGPT settings\n" + f"2. Navigate to MCP Servers section\n" + f"3. Add a new server with:\n" + f" - URL: [cyan]{configured_server_url}[/cyan]\n" + f" - Transport: [cyan]http[/cyan]", + title="Manual Configuration Required", + border_style="yellow", + ) + ) + return + + client_config = CLIENT_CONFIGS[client_lc] + config_path = client_config["path"]() + + server_name = name or _generate_server_name(configured_server_url) + + existing_config = {} + if config_path.exists(): + try: + existing_config = json.loads(config_path.read_text(encoding="utf-8")) + servers = existing_config.get("mcp", {}).get("servers", {}) + if server_name in servers and not force: + raise CLIError( + f"Server '{server_name}' already exists in {config_path}. Use --force to overwrite." + ) + except json.JSONDecodeError as e: + raise CLIError(f"Failed to parse existing config at {config_path}: {e}") from e + + transport = "sse" if configured_server_url.rstrip("/").endswith("/sse") else "http" + server_config = _build_server_config(configured_server_url, transport) + + merged_config = _merge_mcp_json(existing_config, server_name, server_config) + + try: + _write_json(config_path, merged_config) + print_success(f"Server '{server_name}' installed to {config_path}") + except Exception as e: + raise CLIError(f"Failed to write config file: {e}") from e + + console.print( + Panel( + f"[bold green]✅ Installation Complete![/bold green]\n\n" + f"Server: [cyan]{server_name}[/cyan]\n" + f"URL: [cyan]{configured_server_url}[/cyan]\n" + f"Client: [cyan]{client_config['description']}[/cyan]\n" + f"Config: [cyan]{config_path}[/cyan]\n\n" + f"[dim]The server is now available in your {client_config['description']} client.[/dim]", + title="MCP Server Installed", + border_style="green", + ) + ) diff --git a/src/mcp_agent/cli/main.py b/src/mcp_agent/cli/main.py index e305ba224..90726e913 100644 --- a/src/mcp_agent/cli/main.py +++ b/src/mcp_agent/cli/main.py @@ -38,6 +38,7 @@ logs as logs_cmd, doctor as doctor_cmd, configure as configure_cmd, + install as install_cmd, ) from mcp_agent.cli.commands import ( config as config_cmd, @@ -186,6 +187,9 @@ def main( "login", help="Authenticate to MCP Agent Cloud API (alias for 'cloud login')" )(login) +# Register install command as top-level +app.add_typer(install_cmd.app, name="install", help="Install MCP server to client applications") + def run() -> None: """Run the CLI application.""" diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py new file mode 100644 index 000000000..3309f4a24 --- /dev/null +++ b/tests/cli/commands/test_install.py @@ -0,0 +1,521 @@ +"""Tests for the install command.""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp_agent.cli.commands.install import ( + _build_server_config, + _generate_server_name, + _merge_mcp_json, + install, +) +from mcp_agent.cli.exceptions import CLIError +from mcp_agent.cli.mcp_app.mock_client import ( + MOCK_APP_CONFIG_ID, + MOCK_APP_ID, + MOCK_APP_SERVER_URL, +) + + +@pytest.fixture +def mock_mcp_client(): + """Create a mock MCP app client.""" + client = MagicMock() + client.list_config_params = AsyncMock(return_value=[]) + + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + client.get_app = AsyncMock(return_value=mock_app) + + mock_config = MagicMock() + mock_config.appConfigurationId = MOCK_APP_CONFIG_ID + mock_config.appServerInfo = MagicMock() + mock_config.appServerInfo.serverUrl = "https://test-server.example.com/mcp" + client.configure_app = AsyncMock(return_value=mock_config) + + return client + + +def test_generate_server_name(): + """Test server name generation from URLs.""" + assert _generate_server_name("https://api.example.com/servers/my-server/mcp") == "my-server" + assert _generate_server_name("https://api.example.com/mcp") == "api.example.com" + assert _generate_server_name("https://example.com") == "example.com" + + +def test_build_server_config(): + """Test server configuration building.""" + config = _build_server_config("https://example.com/mcp", "http") + assert config == { + "url": "https://example.com/mcp", + "transport": "http", + } + + config_sse = _build_server_config("https://example.com/sse", "sse") + assert config_sse == { + "url": "https://example.com/sse", + "transport": "sse", + } + + +def test_merge_mcp_json_empty(): + """Test merging into empty config.""" + result = _merge_mcp_json({}, "test-server", {"url": "https://example.com", "transport": "http"}) + assert result == { + "mcp": { + "servers": { + "test-server": { + "url": "https://example.com", + "transport": "http", + } + } + } + } + + +def test_merge_mcp_json_existing(): + """Test merging into existing config.""" + existing = { + "mcp": { + "servers": { + "existing-server": { + "url": "https://existing.com", + "transport": "http", + } + } + } + } + result = _merge_mcp_json( + existing, + "new-server", + {"url": "https://new.com", "transport": "http"}, + ) + assert result == { + "mcp": { + "servers": { + "existing-server": { + "url": "https://existing.com", + "transport": "http", + }, + "new-server": { + "url": "https://new.com", + "transport": "http", + }, + } + } + } + + +def test_merge_mcp_json_overwrite(): + """Test overwriting existing server.""" + existing = { + "mcp": { + "servers": { + "test-server": { + "url": "https://old.com", + "transport": "http", + } + } + } + } + result = _merge_mcp_json( + existing, + "test-server", + {"url": "https://new.com", "transport": "sse"}, + ) + assert result == { + "mcp": { + "servers": { + "test-server": { + "url": "https://new.com", + "transport": "sse", + } + } + } + } + + +def test_install_missing_api_key(tmp_path): + """Test install fails without API key.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value=None): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = None + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with pytest.raises(CLIError, match="Must be logged in"): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name=None, + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url=None, + api_key=None, + ) + + +def test_install_invalid_client(): + """Test install fails with invalid client.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + + with pytest.raises(CLIError, match="Unsupported client"): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="invalid-client", + name=None, + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url=None, + api_key=None, + ) + + +def test_install_both_secrets_files(): + """Test install fails with both secrets file options.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + + secrets_file = Path("/tmp/secrets.yaml") + secrets_output_file = Path("/tmp/output.yaml") + + with pytest.raises(CLIError, match="Cannot provide both"): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name=None, + secrets_file=secrets_file, + secrets_output_file=secrets_output_file, + dry_run=False, + force=False, + api_url=None, + api_key=None, + ) + + +def test_install_dry_run(mock_mcp_client, tmp_path, capsys): + """Test install in dry run mode.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MockMCPAppClient", + return_value=mock_mcp_client, + ): + # No exception should be raised + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=True, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify configure_app was not called in dry run + mock_mcp_client.configure_app.assert_not_called() + + +def test_install_vscode_no_secrets(mock_mcp_client, tmp_path): + """Test install to VSCode without secrets.""" + vscode_config = tmp_path / ".vscode" / "mcp.json" + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify config file was created + assert vscode_config.exists() + + # Verify config contents + config = json.loads(vscode_config.read_text()) + assert "mcp" in config + assert "servers" in config["mcp"] + assert "test-server" in config["mcp"]["servers"] + assert config["mcp"]["servers"]["test-server"]["url"] == "https://test-server.example.com/mcp" + assert config["mcp"]["servers"]["test-server"]["transport"] == "http" + + +def test_install_cursor_with_existing_config(mock_mcp_client, tmp_path): + """Test install to Cursor with existing configuration.""" + cursor_config = tmp_path / ".cursor" / "mcp.json" + cursor_config.parent.mkdir(parents=True, exist_ok=True) + + # Create existing config + existing = { + "mcp": { + "servers": { + "existing-server": { + "url": "https://existing.com/mcp", + "transport": "http", + } + } + } + } + cursor_config.write_text(json.dumps(existing, indent=2)) + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch("mcp_agent.cli.commands.install.Path.home", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="cursor", + name="new-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify config file was updated + config = json.loads(cursor_config.read_text()) + assert len(config["mcp"]["servers"]) == 2 + assert "existing-server" in config["mcp"]["servers"] + assert "new-server" in config["mcp"]["servers"] + + +def test_install_duplicate_without_force(mock_mcp_client, tmp_path): + """Test install fails when server already exists without --force.""" + vscode_config = tmp_path / ".vscode" / "mcp.json" + vscode_config.parent.mkdir(parents=True, exist_ok=True) + + # Create existing config with same server name + existing = { + "mcp": { + "servers": { + "test-server": { + "url": "https://old.com/mcp", + "transport": "http", + } + } + } + } + vscode_config.write_text(json.dumps(existing, indent=2)) + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with pytest.raises(CLIError, match="already exists"): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + +def test_install_duplicate_with_force(mock_mcp_client, tmp_path): + """Test install overwrites when server exists with --force.""" + vscode_config = tmp_path / ".vscode" / "mcp.json" + vscode_config.parent.mkdir(parents=True, exist_ok=True) + + # Create existing config with same server name + existing = { + "mcp": { + "servers": { + "test-server": { + "url": "https://old.com/mcp", + "transport": "http", + } + } + } + } + vscode_config.write_text(json.dumps(existing, indent=2)) + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=True, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify config was updated + config = json.loads(vscode_config.read_text()) + assert config["mcp"]["servers"]["test-server"]["url"] == "https://test-server.example.com/mcp" + + +def test_install_chatgpt_prints_instructions(mock_mcp_client, capsys): + """Test install to ChatGPT prints instructions instead of writing config.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="chatgpt", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify configure_app was called + mock_mcp_client.configure_app.assert_called_once() + + +def test_install_with_secrets(mock_mcp_client, tmp_path): + """Test install with required secrets.""" + # Mock client to require secrets + mock_mcp_client.list_config_params = AsyncMock( + return_value=["server.api_key", "server.secret"] + ) + + vscode_config = tmp_path / ".vscode" / "mcp.json" + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text("server:\n api_key: test-key\n secret: test-secret\n") + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch( + "mcp_agent.cli.commands.install.configure_user_secrets", + AsyncMock(return_value={"server.api_key": "id1", "server.secret": "id2"}), + ): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=secrets_file, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify config file was created + assert vscode_config.exists() + + # Verify configure_app was called with secrets + mock_mcp_client.configure_app.assert_called_once() + + +def test_install_sse_transport_detection(mock_mcp_client, tmp_path): + """Test that SSE transport is detected from URL.""" + # Mock to return SSE URL + mock_config = MagicMock() + mock_config.appConfigurationId = MOCK_APP_CONFIG_ID + mock_config.appServerInfo = MagicMock() + mock_config.appServerInfo.serverUrl = "https://test-server.example.com/sse" + mock_mcp_client.configure_app = AsyncMock(return_value=mock_config) + + vscode_config = tmp_path / ".vscode" / "mcp.json" + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + mock_settings.VERBOSE = False + + with patch( + "mcp_agent.cli.commands.install.MCPAppClient", + return_value=mock_mcp_client, + ): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + secrets_file=None, + secrets_output_file=None, + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify SSE transport was used + config = json.loads(vscode_config.read_text()) + assert config["mcp"]["servers"]["test-server"]["transport"] == "sse" From cd4e15d5ef1f1fdbb526aa0fd737b501758a7a2d Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:24:38 +0000 Subject: [PATCH 02/10] formatting --- src/mcp_agent/cli/commands/install.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 5234ce234..5e8f17df3 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -21,7 +21,6 @@ from typing import Optional, Union import typer -from rich.console import Console from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn @@ -240,7 +239,7 @@ def install( f"Treating '{server_identifier}' as server URL. If this is an app ID/name, URL resolution is not yet implemented." ) - console.print(f"\n[bold cyan]Installing MCP Server[/bold cyan]\n") + console.print("\n[bold cyan]Installing MCP Server[/bold cyan]\n") print_info(f"Server: {app_server_url}") print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") From 9076406717cad4a41273827c97881c45e2bdcb23 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:03:45 +0000 Subject: [PATCH 03/10] do not configure deployments --- src/mcp_agent/cli/commands/install.py | 387 +++++++++++------------ tests/cli/commands/test_install.py | 438 ++++++++++++-------------- 2 files changed, 389 insertions(+), 436 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 5e8f17df3..41a5f27eb 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -1,55 +1,63 @@ """ Install command for adding MCP servers to client applications. -Similar to fastmcp install, this command provides end-to-end installation: -1. Authenticates with MCP Agent Cloud -2. Configures server with required secrets -3. Writes server configuration to client config file +This command adds deployed MCP Agent Cloud servers to client config files. +For authenticated clients (Claude Code, Cursor, VSCode, Claude Desktop), the +server URL is added with an Authorization header using your MCP_API_KEY. + +For ChatGPT, the server must have unauthenticated access enabled. Supported clients: - vscode: writes .vscode/mcp.json in project - claude_code: writes ~/.claude/claude_code_config.json - cursor: writes ~/.cursor/mcp.json - - claude_desktop: writes ~/.claude/mcp.json (Claude Desktop) - - chatgpt: prints configuration instructions + - claude_desktop: writes platform-specific Claude Desktop config + - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json + - Windows: ~/AppData/Roaming/Claude/claude_desktop_config.json + - Linux: ~/.config/Claude/claude_desktop_config.json + - chatgpt: requires unauthenticated access enabled """ from __future__ import annotations import json +import platform from pathlib import Path -from typing import Optional, Union +from typing import Optional import typer from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn from mcp_agent.cli.auth import load_api_key_credentials from mcp_agent.cli.config import settings -from mcp_agent.cli.core.api_client import UnauthenticatedError from mcp_agent.cli.core.constants import ( DEFAULT_API_BASE_URL, ENV_API_BASE_URL, ENV_API_KEY, - MCP_CONFIGURED_SECRETS_FILENAME, ) from mcp_agent.cli.core.utils import run_async from mcp_agent.cli.exceptions import CLIError from mcp_agent.cli.mcp_app.api_client import MCPAppClient -from mcp_agent.cli.mcp_app.mock_client import MockMCPAppClient -from mcp_agent.cli.secrets.mock_client import MockSecretsClient -from mcp_agent.cli.secrets.processor import configure_user_secrets from mcp_agent.cli.utils.ux import ( console, - print_configuration_header, print_info, print_success, - print_warning, ) -app = typer.Typer(help="Install MCP server to client applications") +app = typer.Typer(help="Install MCP server to client applications", no_args_is_help=False) + + +def _get_claude_desktop_config_path() -> Path: + """Get the Claude Desktop config path based on platform.""" + if platform.system() == "Darwin": # macOS + return Path.home() / "Library/Application Support/Claude/claude_desktop_config.json" + elif platform.system() == "Windows": + return Path.home() / "AppData/Roaming/Claude/claude_desktop_config.json" + else: # Linux + return Path.home() / ".config/Claude/claude_desktop_config.json" +# Client configuration paths CLIENT_CONFIGS = { "vscode": { "path": lambda: Path.cwd() / ".vscode" / "mcp.json", @@ -64,30 +72,46 @@ "description": "Cursor", }, "claude_desktop": { - "path": lambda: Path.home() / ".claude" / "mcp.json", + "path": _get_claude_desktop_config_path, "description": "Claude Desktop", }, } -def _merge_mcp_json(existing: dict, server_name: str, server_config: dict) -> dict: +def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, use_claude_format: bool = False) -> dict: """ Merge a server configuration into existing MCP JSON. - Accepts various formats and always emits {"mcp":{"servers":{...}}}. + + Args: + existing: Existing config dict + server_name: Name of the server to add/update + server_config: Server configuration dict + use_claude_format: If True, use Claude Desktop format {"mcpServers": {...}} + If False, use standard format {"mcp": {"servers": {...}}} """ servers: dict = {} if isinstance(existing, dict): - if "mcp" in existing and isinstance(existing.get("mcp"), dict): + # Check for Claude Desktop format first + if "mcpServers" in existing and isinstance(existing.get("mcpServers"), dict): + servers = dict(existing["mcpServers"]) + elif "mcp" in existing and isinstance(existing.get("mcp"), dict): servers = dict(existing["mcp"].get("servers") or {}) elif "servers" in existing and isinstance(existing.get("servers"), dict): servers = dict(existing.get("servers") or {}) else: + # Treat top-level mapping as servers if it looks like name->obj for k, v in existing.items(): - if isinstance(v, dict) and ("url" in v or "transport" in v): + if isinstance(v, dict) and ("url" in v or "transport" in v or "command" in v): servers[k] = v + # Add/update the new server servers[server_name] = server_config - return {"mcp": {"servers": servers}} + + # Return in appropriate format + if use_claude_format: + return {"mcpServers": servers} + else: + return {"mcp": {"servers": servers}} def _write_json(path: Path, data: dict) -> None: @@ -97,10 +121,13 @@ def _write_json(path: Path, data: dict) -> None: def _build_server_config(server_url: str, transport: str = "http") -> dict: - """Build server configuration dictionary.""" + """Build server configuration dictionary with auth header.""" return { "url": server_url, "transport": transport, + "headers": { + "Authorization": "Bearer ${MCP_API_KEY}" + } } @@ -112,17 +139,20 @@ def _generate_server_name(server_url: str) -> str: # If URL has path segments (more than protocol://domain) if len(parts) > 3: # ['https:', '', 'domain', 'path', ...] - # Try to get the second-to-last meaningful part - # Skip common MCP path segments + # Try to get the last meaningful part before /mcp or /sse path_parts = [p for p in parts[3:] if p and p not in ('mcp', 'sse')] if path_parts: return path_parts[-1] # Fall back to domain name if len(parts) >= 3: + # Extract domain from parts[2] (after https://) domain = parts[2] + # Remove port if present, extract subdomain domain = domain.split(':')[0] - return domain + # Use first part of subdomain for cleaner name + subdomain = domain.split('.')[0] + return subdomain return "server" @@ -130,7 +160,7 @@ def _generate_server_name(server_url: str) -> str: @app.callback(invoke_without_command=True) def install( server_identifier: str = typer.Argument( - ..., help="Server URL, app ID, or app name to install" + ..., help="Server URL or app ID to install" ), client: str = typer.Option( ..., "--client", "-c", help="Client to install to: vscode|claude_code|cursor|claude_desktop|chatgpt" @@ -138,25 +168,8 @@ def install( name: Optional[str] = typer.Option( None, "--name", "-n", help="Server name in client config (auto-generated if not provided)" ), - secrets_file: Optional[Path] = typer.Option( - None, - "--secrets-file", - "-s", - help="Path to secrets.yaml file with user secret IDs. If not provided, secrets will be prompted interactively.", - exists=True, - readable=True, - dir_okay=False, - resolve_path=True, - ), - secrets_output_file: Optional[Path] = typer.Option( - None, - "--secrets-output-file", - "-o", - help="Path to write configured secrets. Defaults to mcp_agent.configured.secrets.yaml", - resolve_path=True, - ), dry_run: bool = typer.Option( - False, "--dry-run", help="Validate configuration but don't install" + False, "--dry-run", help="Show what would be installed without writing files" ), force: bool = typer.Option( False, "--force", "-f", help="Overwrite existing server configuration" @@ -177,196 +190,142 @@ def install( """ Install an MCP server to a client application. - This command: - 1. Authenticates with MCP Agent Cloud - 2. Configures the server with required secrets - 3. Writes the server configuration to the client's config file + This command writes the server configuration to the client's config file. + For authenticated clients (everything except ChatGPT), the server URL is + added with an Authorization header using your MCP_API_KEY environment variable. + + URLs without /sse or /mcp suffix will automatically have /sse appended and + use SSE transport for optimal performance. + + For ChatGPT, the server must have unauthenticated access enabled. Examples: - # Install to VSCode - mcp-agent install https://api.example.com/servers/my-server/mcp --client vscode + # Install to VSCode (automatically appends /sse) + mcp-agent install --client=vscode https://xxx.deployments.mcp-agent.com # Install to Claude Code with custom name - mcp-agent install app-id-123 --client claude_code --name my-server + mcp-agent install --client=claude_code --name=my-server https://xxx.deployments.mcp-agent.com - # Install with existing secrets file - mcp-agent install my-server --client cursor --secrets-file secrets.yaml + # Install to ChatGPT (requires unauthenticated access) + mcp-agent install --client=chatgpt https://xxx.deployments.mcp-agent.com """ client_lc = client.lower() + # Validate client if client_lc not in CLIENT_CONFIGS and client_lc != "chatgpt": raise CLIError( f"Unsupported client: {client}. Supported clients: vscode, claude_code, cursor, claude_desktop, chatgpt" ) + # Authenticate effective_api_key = api_key or settings.API_KEY or load_api_key_credentials() if not effective_api_key: raise CLIError( "Must be logged in to install. Run 'mcp-agent login', set MCP_API_KEY environment variable, or specify --api-key option." ) - mcp_client: Union[MockMCPAppClient, MCPAppClient] - if dry_run: - print_info("Using MOCK API client for dry run") - mcp_client = MockMCPAppClient( - api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key - ) - else: - mcp_client = MCPAppClient( - api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key - ) - - if secrets_file and secrets_output_file: - raise CLIError( - "Cannot provide both --secrets-file and --secrets-output-file. Please specify only one." - ) - if secrets_file and not secrets_file.suffix == ".yaml": - raise CLIError( - "The --secrets-file must be a YAML file. Please provide a valid path." - ) - if secrets_output_file and not secrets_output_file.suffix == ".yaml": + # Normalize server URL + server_url = server_identifier + if not server_identifier.startswith("http://") and not server_identifier.startswith("https://"): raise CLIError( - "The --secrets-output-file must be a YAML file. Please provide a valid path." + f"Server identifier must be a URL starting with http:// or https://. Got: {server_identifier}" ) - # Normalize server identifier to URL - app_server_url = server_identifier - if not server_identifier.startswith("http://") and not server_identifier.startswith("https://"): - # Could be app ID or name - try to resolve via API - # For now, treat as URL and let API handle validation - # In future, could add app lookup by ID/name - print_warning( - f"Treating '{server_identifier}' as server URL. If this is an app ID/name, URL resolution is not yet implemented." - ) + # Ensure URL ends with /sse for MCP Agent Cloud deployments + if not server_url.endswith("/sse") and not server_url.endswith("/mcp"): + server_url = server_url.rstrip("/") + "/sse" + print_info(f"Using SSE transport: {server_url}") - console.print("\n[bold cyan]Installing MCP Server[/bold cyan]\n") - print_info(f"Server: {app_server_url}") + console.print(f"\n[bold cyan]Installing MCP Server[/bold cyan]\n") + print_info(f"Server: {server_url}") print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") - required_params = [] - try: - with Progress( - SpinnerColumn(spinner_name="arrow3"), - TextColumn("[progress.description]{task.description}"), - ) as progress: - task = progress.add_task("Checking server requirements...", total=None) - required_params = run_async( - mcp_client.list_config_params(app_server_url=app_server_url) - ) - progress.update(task, description="✅ Server requirements checked") - except UnauthenticatedError as e: - raise CLIError( - "Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable." - ) from e - except Exception as e: - raise CLIError( - f"Failed to retrieve server requirements: {e}" - ) from e - - configured_secrets = {} - requires_secrets = len(required_params) > 0 - - if requires_secrets: - if not secrets_file and secrets_output_file is None: - secrets_output_file = Path(MCP_CONFIGURED_SECRETS_FILENAME) - print_info(f"Using default secrets output: {secrets_output_file}") - - print_configuration_header(secrets_file, secrets_output_file, dry_run) - print_info( - f"Server requires {len(required_params)} secret(s): {', '.join(required_params)}" + # For ChatGPT, check if server has unauthenticated access enabled + if client_lc == "chatgpt": + mcp_client = MCPAppClient( + api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key ) try: - print_info("Processing user secrets...") + # Try to get app info to check unauthenticated access + app_info = run_async(mcp_client.get_app(server_url=server_url)) - if dry_run: - print_info("Using MOCK Secrets API client for dry run") - mock_secrets_client = MockSecretsClient( - api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key - ) - configured_secrets = run_async( - configure_user_secrets( - required_secrets=required_params, - config_path=secrets_file, - output_path=secrets_output_file, - client=mock_secrets_client, - ) - ) - else: - configured_secrets = run_async( - configure_user_secrets( - required_secrets=required_params, - config_path=secrets_file, - output_path=secrets_output_file, - api_url=api_url, - api_key=effective_api_key, - ) - ) - - print_success("User secrets processed successfully") - - except Exception as e: - if settings.VERBOSE: - import traceback - typer.echo(traceback.format_exc()) - raise CLIError(f"Failed to process secrets: {e}") from e - else: - print_info("Server does not require any secrets") - if secrets_file: - raise CLIError( - f"Server does not require secrets, but a secrets file was provided: {secrets_file}" + has_unauth_access = ( + app_info.unauthenticatedAccess == True or + (app_info.appServerInfo and app_info.appServerInfo.unauthenticatedAccess == True) ) - if dry_run: - print_success("Installation completed in dry run mode (no files written)") - return - - configured_server_url = app_server_url - try: - with Progress( - SpinnerColumn(spinner_name="arrow3"), - TextColumn("[progress.description]{task.description}"), - ) as progress: - task = progress.add_task("Configuring server...", total=None) - config = run_async( - mcp_client.configure_app( - app_server_url=app_server_url, config_params=configured_secrets + if not has_unauth_access: + console.print( + Panel( + f"[bold red]❌ ChatGPT Requires Unauthenticated Access[/bold red]\n\n" + f"This server requires authentication, but ChatGPT only supports:\n" + f" • Unauthenticated (public) servers\n" + f" • OAuth (not yet supported by mcp-agent install)\n\n" + f"[bold]Options:[/bold]\n\n" + f"1. Enable unauthenticated access for this server:\n" + f" [cyan]mcp-agent cloud apps update --id {app_info.appId} --unauthenticated-access true[/cyan]\n\n" + f"2. Use a client that supports authentication:\n" + f" [green]• Claude Code:[/green] mcp-agent install {server_url} --client claude_code\n" + f" [green]• Claude Desktop:[/green] mcp-agent install {server_url} --client claude_desktop\n" + f" [green]• Cursor:[/green] mcp-agent install {server_url} --client cursor\n" + f" [green]• VSCode:[/green] mcp-agent install {server_url} --client vscode", + title="Installation Failed", + border_style="red", + ) ) - ) - progress.update(task, description="✅ Server configured") + raise typer.Exit(1) - if config.appServerInfo and config.appServerInfo.serverUrl: - configured_server_url = config.appServerInfo.serverUrl - print_info(f"Configured server URL: {configured_server_url}") - - except Exception as e: - raise CLIError(f"Failed to configure server: {e}") from e + except typer.Exit: + # Re-raise typer.Exit to properly exit + raise + except Exception as e: + # If we can't fetch app info, warn but continue + print_info(f"Warning: Could not verify unauthenticated access: {e}") + print_info("Proceeding with installation, but ChatGPT may not be able to connect.") - if client_lc == "chatgpt": + # Show ChatGPT instructions console.print( Panel( - f"[bold]ChatGPT Configuration Instructions[/bold]\n\n" + f"[bold]ChatGPT Setup Instructions[/bold]\n\n" f"1. Open ChatGPT settings\n" - f"2. Navigate to MCP Servers section\n" - f"3. Add a new server with:\n" - f" - URL: [cyan]{configured_server_url}[/cyan]\n" - f" - Transport: [cyan]http[/cyan]", - title="Manual Configuration Required", - border_style="yellow", + f"2. Navigate to the Apps & Connectors section\n" + f"3. Enable developer mode under advanced settings\n" + f"4. Select create on the top right corner of the panel\n" + f"5. Add a new server:\n" + f" • URL: [cyan]{server_url}/sse[/cyan]\n" + f" • Transport: [cyan]sse[/cyan]\n\n" + f"[dim]Note: This server has unauthenticated access enabled.[/dim]", + title="ChatGPT Configuration", + border_style="green", ) ) return + # For other clients, write to config file + if dry_run: + print_info("[bold yellow]DRY RUN - No files will be written[/bold yellow]") + client_config = CLIENT_CONFIGS[client_lc] config_path = client_config["path"]() - server_name = name or _generate_server_name(configured_server_url) + # Generate server name + server_name = name or _generate_server_name(server_url) + # Determine if we're using Claude Desktop format + use_claude_format = client_lc == "claude_desktop" + + # Check existing config existing_config = {} if config_path.exists(): try: existing_config = json.loads(config_path.read_text(encoding="utf-8")) - servers = existing_config.get("mcp", {}).get("servers", {}) + # Check in appropriate location based on format + if use_claude_format: + servers = existing_config.get("mcpServers", {}) + else: + servers = existing_config.get("mcp", {}).get("servers", {}) + if server_name in servers and not force: raise CLIError( f"Server '{server_name}' already exists in {config_path}. Use --force to overwrite." @@ -374,26 +333,38 @@ def install( except json.JSONDecodeError as e: raise CLIError(f"Failed to parse existing config at {config_path}: {e}") from e - transport = "sse" if configured_server_url.rstrip("/").endswith("/sse") else "http" - server_config = _build_server_config(configured_server_url, transport) - - merged_config = _merge_mcp_json(existing_config, server_name, server_config) - - try: - _write_json(config_path, merged_config) - print_success(f"Server '{server_name}' installed to {config_path}") - except Exception as e: - raise CLIError(f"Failed to write config file: {e}") from e - - console.print( - Panel( - f"[bold green]✅ Installation Complete![/bold green]\n\n" - f"Server: [cyan]{server_name}[/cyan]\n" - f"URL: [cyan]{configured_server_url}[/cyan]\n" - f"Client: [cyan]{client_config['description']}[/cyan]\n" - f"Config: [cyan]{config_path}[/cyan]\n\n" - f"[dim]The server is now available in your {client_config['description']} client.[/dim]", - title="MCP Server Installed", - border_style="green", + # Determine transport type + transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" + + # Build server config with auth header + server_config = _build_server_config(server_url, transport) + + # Merge with existing config + merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_claude_format) + + # Write or show config + if dry_run: + console.print("\n[bold]Would write to:[/bold]", config_path) + console.print("\n[bold]Config:[/bold]") + console.print_json(data=merged_config) + else: + try: + _write_json(config_path, merged_config) + print_success(f"Server '{server_name}' installed to {config_path}") + except Exception as e: + raise CLIError(f"Failed to write config file: {e}") from e + + # Success message + console.print( + Panel( + f"[bold green]✅ Installation Complete![/bold green]\n\n" + f"Server: [cyan]{server_name}[/cyan]\n" + f"URL: [cyan]{server_url}[/cyan]\n" + f"Client: [cyan]{client_config['description']}[/cyan]\n" + f"Config: [cyan]{config_path}[/cyan]\n\n" + f"[bold]Authentication:[/bold] Set [cyan]MCP_API_KEY[/cyan] environment variable\n" + f"[dim]The server will authenticate using: Authorization: Bearer $MCP_API_KEY[/dim]", + title="MCP Server Installed", + border_style="green", + ) ) - ) diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index 3309f4a24..c5900c587 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -12,69 +12,101 @@ install, ) from mcp_agent.cli.exceptions import CLIError -from mcp_agent.cli.mcp_app.mock_client import ( - MOCK_APP_CONFIG_ID, - MOCK_APP_ID, - MOCK_APP_SERVER_URL, -) -@pytest.fixture -def mock_mcp_client(): - """Create a mock MCP app client.""" - client = MagicMock() - client.list_config_params = AsyncMock(return_value=[]) +MOCK_APP_SERVER_URL = "https://test-server.example.com/sse" - mock_app = MagicMock() - mock_app.appId = MOCK_APP_ID - client.get_app = AsyncMock(return_value=mock_app) - mock_config = MagicMock() - mock_config.appConfigurationId = MOCK_APP_CONFIG_ID - mock_config.appServerInfo = MagicMock() - mock_config.appServerInfo.serverUrl = "https://test-server.example.com/mcp" - client.configure_app = AsyncMock(return_value=mock_config) +@pytest.fixture +def mock_app_with_auth(): + """Create a mock app that requires authentication.""" + app = MagicMock() + app.appId = "app-123" + app.unauthenticatedAccess = False + app.appServerInfo = MagicMock() + app.appServerInfo.serverUrl = MOCK_APP_SERVER_URL + app.appServerInfo.unauthenticatedAccess = False + return app - return client + +@pytest.fixture +def mock_app_without_auth(): + """Create a mock app with unauthenticated access.""" + app = MagicMock() + app.appId = "app-456" + app.unauthenticatedAccess = True + app.appServerInfo = MagicMock() + app.appServerInfo.serverUrl = MOCK_APP_SERVER_URL + app.appServerInfo.unauthenticatedAccess = True + return app def test_generate_server_name(): """Test server name generation from URLs.""" + assert _generate_server_name("https://z53gajrsdkssfgjmgaka1i27crthugq.deployments.mcp-agent.com/sse") == "z53gajrsdkssfgjmgaka1i27crthugq" assert _generate_server_name("https://api.example.com/servers/my-server/mcp") == "my-server" - assert _generate_server_name("https://api.example.com/mcp") == "api.example.com" - assert _generate_server_name("https://example.com") == "example.com" + assert _generate_server_name("https://example.com") == "example" def test_build_server_config(): - """Test server configuration building.""" + """Test server configuration building with auth header.""" config = _build_server_config("https://example.com/mcp", "http") assert config == { "url": "https://example.com/mcp", "transport": "http", + "headers": { + "Authorization": "Bearer ${MCP_API_KEY}" + } } config_sse = _build_server_config("https://example.com/sse", "sse") assert config_sse == { "url": "https://example.com/sse", "transport": "sse", + "headers": { + "Authorization": "Bearer ${MCP_API_KEY}" + } } def test_merge_mcp_json_empty(): """Test merging into empty config.""" - result = _merge_mcp_json({}, "test-server", {"url": "https://example.com", "transport": "http"}) + result = _merge_mcp_json({}, "test-server", { + "url": "https://example.com", + "transport": "http", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + }) assert result == { "mcp": { "servers": { "test-server": { "url": "https://example.com", "transport": "http", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} } } } } +def test_merge_mcp_json_claude_format(): + """Test merging with Claude Desktop format.""" + result = _merge_mcp_json({}, "test-server", { + "url": "https://example.com", + "transport": "http", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + }, use_claude_format=True) + assert result == { + "mcpServers": { + "test-server": { + "url": "https://example.com", + "transport": "http", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + } + } + } + + def test_merge_mcp_json_existing(): """Test merging into existing config.""" existing = { @@ -90,7 +122,7 @@ def test_merge_mcp_json_existing(): result = _merge_mcp_json( existing, "new-server", - {"url": "https://new.com", "transport": "http"}, + {"url": "https://new.com", "transport": "http", "headers": {"Authorization": "Bearer ${MCP_API_KEY}"}}, ) assert result == { "mcp": { @@ -102,6 +134,7 @@ def test_merge_mcp_json_existing(): "new-server": { "url": "https://new.com", "transport": "http", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} }, } } @@ -123,7 +156,7 @@ def test_merge_mcp_json_overwrite(): result = _merge_mcp_json( existing, "test-server", - {"url": "https://new.com", "transport": "sse"}, + {"url": "https://new.com", "transport": "sse", "headers": {"Authorization": "Bearer ${MCP_API_KEY}"}}, ) assert result == { "mcp": { @@ -131,6 +164,7 @@ def test_merge_mcp_json_overwrite(): "test-server": { "url": "https://new.com", "transport": "sse", + "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} } } } @@ -143,15 +177,12 @@ def test_install_missing_api_key(tmp_path): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = None mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False with pytest.raises(CLIError, match="Must be logged in"): install( server_identifier=MOCK_APP_SERVER_URL, client="vscode", name=None, - secrets_file=None, - secrets_output_file=None, dry_run=False, force=False, api_url=None, @@ -171,8 +202,6 @@ def test_install_invalid_client(): server_identifier=MOCK_APP_SERVER_URL, client="invalid-client", name=None, - secrets_file=None, - secrets_output_file=None, dry_run=False, force=False, api_url=None, @@ -180,23 +209,18 @@ def test_install_invalid_client(): ) -def test_install_both_secrets_files(): - """Test install fails with both secrets file options.""" +def test_install_invalid_url(): + """Test install fails with non-URL identifier.""" with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - secrets_file = Path("/tmp/secrets.yaml") - secrets_output_file = Path("/tmp/output.yaml") - - with pytest.raises(CLIError, match="Cannot provide both"): + with pytest.raises(CLIError, match="must be a URL"): install( - server_identifier=MOCK_APP_SERVER_URL, + server_identifier="not-a-url", client="vscode", name=None, - secrets_file=secrets_file, - secrets_output_file=secrets_output_file, dry_run=False, force=False, api_url=None, @@ -204,75 +228,41 @@ def test_install_both_secrets_files(): ) -def test_install_dry_run(mock_mcp_client, tmp_path, capsys): - """Test install in dry run mode.""" +def test_install_vscode(tmp_path): + """Test install to VSCode.""" + vscode_config = tmp_path / ".vscode" / "mcp.json" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - with patch( - "mcp_agent.cli.commands.install.MockMCPAppClient", - return_value=mock_mcp_client, - ): - # No exception should be raised + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): install( server_identifier=MOCK_APP_SERVER_URL, client="vscode", name="test-server", - secrets_file=None, - secrets_output_file=None, - dry_run=True, + dry_run=False, force=False, api_url="http://test-api", api_key="test-key", ) - # Verify configure_app was not called in dry run - mock_mcp_client.configure_app.assert_not_called() + # Verify config file was created + assert vscode_config.exists() + # Verify config contents + config = json.loads(vscode_config.read_text()) + assert "mcp" in config + assert "servers" in config["mcp"] + assert "test-server" in config["mcp"]["servers"] + server = config["mcp"]["servers"]["test-server"] + assert server["url"] == MOCK_APP_SERVER_URL + assert server["transport"] == "sse" + assert server["headers"]["Authorization"] == "Bearer ${MCP_API_KEY}" -def test_install_vscode_no_secrets(mock_mcp_client, tmp_path): - """Test install to VSCode without secrets.""" - vscode_config = tmp_path / ".vscode" / "mcp.json" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): - with patch("mcp_agent.cli.commands.install.settings") as mock_settings: - mock_settings.API_KEY = "test-key" - mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): - install( - server_identifier=MOCK_APP_SERVER_URL, - client="vscode", - name="test-server", - secrets_file=None, - secrets_output_file=None, - dry_run=False, - force=False, - api_url="http://test-api", - api_key="test-key", - ) - - # Verify config file was created - assert vscode_config.exists() - - # Verify config contents - config = json.loads(vscode_config.read_text()) - assert "mcp" in config - assert "servers" in config["mcp"] - assert "test-server" in config["mcp"]["servers"] - assert config["mcp"]["servers"]["test-server"]["url"] == "https://test-server.example.com/mcp" - assert config["mcp"]["servers"]["test-server"]["transport"] == "http" - - -def test_install_cursor_with_existing_config(mock_mcp_client, tmp_path): +def test_install_cursor_with_existing_config(tmp_path): """Test install to Cursor with existing configuration.""" cursor_config = tmp_path / ".cursor" / "mcp.json" cursor_config.parent.mkdir(parents=True, exist_ok=True) @@ -294,33 +284,26 @@ def test_install_cursor_with_existing_config(mock_mcp_client, tmp_path): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch("mcp_agent.cli.commands.install.Path.home", return_value=tmp_path): - install( - server_identifier=MOCK_APP_SERVER_URL, - client="cursor", - name="new-server", - secrets_file=None, - secrets_output_file=None, - dry_run=False, - force=False, - api_url="http://test-api", - api_key="test-key", - ) + with patch("mcp_agent.cli.commands.install.Path.home", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="cursor", + name="new-server", + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) - # Verify config file was updated - config = json.loads(cursor_config.read_text()) - assert len(config["mcp"]["servers"]) == 2 - assert "existing-server" in config["mcp"]["servers"] - assert "new-server" in config["mcp"]["servers"] + # Verify config file was updated + config = json.loads(cursor_config.read_text()) + assert len(config["mcp"]["servers"]) == 2 + assert "existing-server" in config["mcp"]["servers"] + assert "new-server" in config["mcp"]["servers"] -def test_install_duplicate_without_force(mock_mcp_client, tmp_path): +def test_install_duplicate_without_force(tmp_path): """Test install fails when server already exists without --force.""" vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) @@ -342,28 +325,21 @@ def test_install_duplicate_without_force(mock_mcp_client, tmp_path): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): - with pytest.raises(CLIError, match="already exists"): - install( - server_identifier=MOCK_APP_SERVER_URL, - client="vscode", - name="test-server", - secrets_file=None, - secrets_output_file=None, - dry_run=False, - force=False, - api_url="http://test-api", - api_key="test-key", - ) - - -def test_install_duplicate_with_force(mock_mcp_client, tmp_path): + + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with pytest.raises(CLIError, match="already exists"): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + +def test_install_duplicate_with_force(tmp_path): """Test install overwrites when server exists with --force.""" vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) @@ -385,137 +361,143 @@ def test_install_duplicate_with_force(mock_mcp_client, tmp_path): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + dry_run=False, + force=True, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify config was updated + config = json.loads(vscode_config.read_text()) + assert config["mcp"]["servers"]["test-server"]["url"] == MOCK_APP_SERVER_URL + + +def test_install_chatgpt_requires_unauth_access(mock_app_with_auth): + """Test ChatGPT install fails when server requires authentication.""" + import typer + + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" + + with patch("mcp_agent.cli.commands.install.MCPAppClient") as mock_client_class: + mock_client = MagicMock() + mock_client.get_app = AsyncMock(return_value=mock_app_with_auth) + mock_client_class.return_value = mock_client + + with pytest.raises(typer.Exit) as exc_info: install( server_identifier=MOCK_APP_SERVER_URL, - client="vscode", - name="test-server", - secrets_file=None, - secrets_output_file=None, + client="chatgpt", + name=None, dry_run=False, - force=True, + force=False, api_url="http://test-api", api_key="test-key", ) - # Verify config was updated - config = json.loads(vscode_config.read_text()) - assert config["mcp"]["servers"]["test-server"]["url"] == "https://test-server.example.com/mcp" + assert exc_info.value.exit_code == 1 -def test_install_chatgpt_prints_instructions(mock_mcp_client, capsys): - """Test install to ChatGPT prints instructions instead of writing config.""" +def test_install_chatgpt_with_unauth_server(mock_app_without_auth): + """Test ChatGPT install succeeds with unauthenticated server.""" with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): + with patch("mcp_agent.cli.commands.install.MCPAppClient") as mock_client_class: + mock_client = MagicMock() + mock_client.get_app = AsyncMock(return_value=mock_app_without_auth) + mock_client_class.return_value = mock_client + + # Should not raise, just print instructions install( server_identifier=MOCK_APP_SERVER_URL, client="chatgpt", - name="test-server", - secrets_file=None, - secrets_output_file=None, + name=None, dry_run=False, force=False, api_url="http://test-api", api_key="test-key", ) - # Verify configure_app was called - mock_mcp_client.configure_app.assert_called_once() +def test_install_dry_run(tmp_path, capsys): + """Test install in dry run mode.""" + with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch("mcp_agent.cli.commands.install.settings") as mock_settings: + mock_settings.API_KEY = "test-key" + mock_settings.API_BASE_URL = "http://test-api" -def test_install_with_secrets(mock_mcp_client, tmp_path): - """Test install with required secrets.""" - # Mock client to require secrets - mock_mcp_client.list_config_params = AsyncMock( - return_value=["server.api_key", "server.secret"] - ) + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier=MOCK_APP_SERVER_URL, + client="vscode", + name="test-server", + dry_run=True, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify no files were written + vscode_config = tmp_path / ".vscode" / "mcp.json" + assert not vscode_config.exists() + +def test_install_sse_transport_detection(tmp_path): + """Test that SSE transport is detected from URL.""" vscode_config = tmp_path / ".vscode" / "mcp.json" - secrets_file = tmp_path / "secrets.yaml" - secrets_file.write_text("server:\n api_key: test-key\n secret: test-secret\n") with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch( - "mcp_agent.cli.commands.install.configure_user_secrets", - AsyncMock(return_value={"server.api_key": "id1", "server.secret": "id2"}), - ): - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): - install( - server_identifier=MOCK_APP_SERVER_URL, - client="vscode", - name="test-server", - secrets_file=secrets_file, - secrets_output_file=None, - dry_run=False, - force=False, - api_url="http://test-api", - api_key="test-key", - ) - - # Verify config file was created - assert vscode_config.exists() - - # Verify configure_app was called with secrets - mock_mcp_client.configure_app.assert_called_once() - - -def test_install_sse_transport_detection(mock_mcp_client, tmp_path): - """Test that SSE transport is detected from URL.""" - # Mock to return SSE URL - mock_config = MagicMock() - mock_config.appConfigurationId = MOCK_APP_CONFIG_ID - mock_config.appServerInfo = MagicMock() - mock_config.appServerInfo.serverUrl = "https://test-server.example.com/sse" - mock_mcp_client.configure_app = AsyncMock(return_value=mock_config) + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier="https://example.com/sse", + client="vscode", + name="test-server", + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) + + # Verify SSE transport was used + config = json.loads(vscode_config.read_text()) + assert config["mcp"]["servers"]["test-server"]["transport"] == "sse" + + +def test_install_http_transport_detection(tmp_path): + """Test that HTTP transport is detected from URL.""" vscode_config = tmp_path / ".vscode" / "mcp.json" with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - mock_settings.VERBOSE = False - with patch( - "mcp_agent.cli.commands.install.MCPAppClient", - return_value=mock_mcp_client, - ): - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): - install( - server_identifier=MOCK_APP_SERVER_URL, - client="vscode", - name="test-server", - secrets_file=None, - secrets_output_file=None, - dry_run=False, - force=False, - api_url="http://test-api", - api_key="test-key", - ) + with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + install( + server_identifier="https://example.com/mcp", + client="vscode", + name="test-server", + dry_run=False, + force=False, + api_url="http://test-api", + api_key="test-key", + ) - # Verify SSE transport was used - config = json.loads(vscode_config.read_text()) - assert config["mcp"]["servers"]["test-server"]["transport"] == "sse" + # Verify HTTP transport was used + config = json.loads(vscode_config.read_text()) + assert config["mcp"]["servers"]["test-server"]["transport"] == "http" From 353bdd1faab1a36ec6d126c3c1357d274124e3f4 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:10:10 +0000 Subject: [PATCH 04/10] claude desktop formatting --- src/mcp_agent/cli/commands/install.py | 48 ++++++++++++++++++++------- tests/cli/commands/test_install.py | 12 +++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 41a5f27eb..c3563e998 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -11,7 +11,7 @@ - vscode: writes .vscode/mcp.json in project - claude_code: writes ~/.claude/claude_code_config.json - cursor: writes ~/.cursor/mcp.json - - claude_desktop: writes platform-specific Claude Desktop config + - claude_desktop: writes platform-specific Claude Desktop config using mcp-remote wrapper - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Windows: ~/AppData/Roaming/Claude/claude_desktop_config.json - Linux: ~/.config/Claude/claude_desktop_config.json @@ -120,15 +120,32 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") -def _build_server_config(server_url: str, transport: str = "http") -> dict: - """Build server configuration dictionary with auth header.""" - return { - "url": server_url, - "transport": transport, - "headers": { - "Authorization": "Bearer ${MCP_API_KEY}" +def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False) -> dict: + """Build server configuration dictionary with auth header. + + For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper. + For other clients, uses direct HTTP/SSE connection. + """ + if for_claude_desktop: + # Claude Desktop requires stdio wrapper using mcp-remote + return { + "command": "npx", + "args": [ + "mcp-remote", + server_url, + "--header", + "Authorization: Bearer ${MCP_API_KEY}" + ] + } + else: + # Direct HTTP/SSE connection for other clients + return { + "url": server_url, + "transport": transport, + "headers": { + "Authorization": "Bearer ${MCP_API_KEY}" + } } - } def _generate_server_name(server_url: str) -> str: @@ -217,6 +234,7 @@ def install( f"Unsupported client: {client}. Supported clients: vscode, claude_code, cursor, claude_desktop, chatgpt" ) + # Authenticate effective_api_key = api_key or settings.API_KEY or load_api_key_credentials() if not effective_api_key: @@ -336,8 +354,8 @@ def install( # Determine transport type transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - # Build server config with auth header - server_config = _build_server_config(server_url, transport) + # Build server config (with mcp-remote wrapper for Claude Desktop) + server_config = _build_server_config(server_url, transport, for_claude_desktop=use_claude_format) # Merge with existing config merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_claude_format) @@ -355,6 +373,12 @@ def install( raise CLIError(f"Failed to write config file: {e}") from e # Success message + auth_note = ( + "[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers" + if use_claude_format + else "[bold]Authentication:[/bold] Set [cyan]MCP_API_KEY[/cyan] environment variable" + ) + console.print( Panel( f"[bold green]✅ Installation Complete![/bold green]\n\n" @@ -362,7 +386,7 @@ def install( f"URL: [cyan]{server_url}[/cyan]\n" f"Client: [cyan]{client_config['description']}[/cyan]\n" f"Config: [cyan]{config_path}[/cyan]\n\n" - f"[bold]Authentication:[/bold] Set [cyan]MCP_API_KEY[/cyan] environment variable\n" + f"{auth_note}\n" f"[dim]The server will authenticate using: Authorization: Bearer $MCP_API_KEY[/dim]", title="MCP Server Installed", border_style="green", diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index c5900c587..5e24e81a9 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -68,6 +68,18 @@ def test_build_server_config(): } } + # Claude Desktop uses mcp-remote wrapper + config_claude = _build_server_config("https://example.com/sse", "sse", for_claude_desktop=True) + assert config_claude == { + "command": "npx", + "args": [ + "mcp-remote", + "https://example.com/sse", + "--header", + "Authorization: Bearer ${MCP_API_KEY}" + ] + } + def test_merge_mcp_json_empty(): """Test merging into empty config.""" From 8dee8263aab81efc754d15090a8f1dc7e6850102 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:25:26 +0000 Subject: [PATCH 05/10] update naming --- src/mcp_agent/cli/commands/install.py | 79 ++++++++++++++++++--------- tests/cli/commands/test_install.py | 32 +++++------ 2 files changed, 70 insertions(+), 41 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index c3563e998..2c0acc4b0 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -120,30 +120,39 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") -def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False) -> dict: +def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, api_key: str = None) -> dict: """Build server configuration dictionary with auth header. - For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper. - For other clients, uses direct HTTP/SSE connection. + For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper with actual API key. + For other clients, uses direct HTTP/SSE connection with actual API key embedded. + + Args: + server_url: The server URL + transport: Transport type (http or sse) + for_claude_desktop: Whether to use Claude Desktop format with mcp-remote + api_key: The actual API key (required for all clients) """ + if not api_key: + raise ValueError("API key is required for server configuration") + if for_claude_desktop: - # Claude Desktop requires stdio wrapper using mcp-remote + # Claude Desktop requires stdio wrapper using mcp-remote with actual API key return { "command": "npx", "args": [ "mcp-remote", server_url, "--header", - "Authorization: Bearer ${MCP_API_KEY}" + f"Authorization: Bearer {api_key}" ] } else: - # Direct HTTP/SSE connection for other clients + # Direct HTTP/SSE connection for other clients with embedded API key return { "url": server_url, "transport": transport, "headers": { - "Authorization": "Bearer ${MCP_API_KEY}" + "Authorization": f"Bearer {api_key}" } } @@ -255,18 +264,29 @@ def install( print_info(f"Using SSE transport: {server_url}") console.print(f"\n[bold cyan]Installing MCP Server[/bold cyan]\n") - print_info(f"Server: {server_url}") + print_info(f"Server URL: {server_url}") print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") + # Fetch app info to get the actual app name + mcp_client = MCPAppClient( + api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key + ) + + try: + app_info = run_async(mcp_client.get_app(server_url=server_url)) + app_name = app_info.name if app_info else None + print_info(f"App name: {app_name}") + except Exception as e: + print_info(f"Warning: Could not fetch app info: {e}") + app_name = None + # For ChatGPT, check if server has unauthenticated access enabled if client_lc == "chatgpt": - mcp_client = MCPAppClient( - api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key - ) - try: - # Try to get app info to check unauthenticated access - app_info = run_async(mcp_client.get_app(server_url=server_url)) + # Check unauthenticated access from app_info we already fetched + if not app_info: + # Try to fetch again if we don't have it + app_info = run_async(mcp_client.get_app(server_url=server_url)) has_unauth_access = ( app_info.unauthenticatedAccess == True or @@ -327,8 +347,8 @@ def install( client_config = CLIENT_CONFIGS[client_lc] config_path = client_config["path"]() - # Generate server name - server_name = name or _generate_server_name(server_url) + # Use app name from API, fallback to custom name or generated name + server_name = name or app_name or _generate_server_name(server_url) # Determine if we're using Claude Desktop format use_claude_format = client_lc == "claude_desktop" @@ -354,8 +374,13 @@ def install( # Determine transport type transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - # Build server config (with mcp-remote wrapper for Claude Desktop) - server_config = _build_server_config(server_url, transport, for_claude_desktop=use_claude_format) + # Build server config with embedded API key (all clients) + server_config = _build_server_config( + server_url, + transport, + for_claude_desktop=use_claude_format, + api_key=effective_api_key + ) # Merge with existing config merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_claude_format) @@ -373,11 +398,16 @@ def install( raise CLIError(f"Failed to write config file: {e}") from e # Success message - auth_note = ( - "[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers" - if use_claude_format - else "[bold]Authentication:[/bold] Set [cyan]MCP_API_KEY[/cyan] environment variable" - ) + if use_claude_format: + auth_note = ( + f"[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" + f"[dim]API key embedded in config[/dim]" + ) + else: + auth_note = ( + f"[bold]Authentication:[/bold] API key embedded in config\n" + f"[dim]To update the key, re-run install with --force[/dim]" + ) console.print( Panel( @@ -386,8 +416,7 @@ def install( f"URL: [cyan]{server_url}[/cyan]\n" f"Client: [cyan]{client_config['description']}[/cyan]\n" f"Config: [cyan]{config_path}[/cyan]\n\n" - f"{auth_note}\n" - f"[dim]The server will authenticate using: Authorization: Bearer $MCP_API_KEY[/dim]", + f"{auth_note}", title="MCP Server Installed", border_style="green", ) diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index 5e24e81a9..b5f8bfe06 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -50,33 +50,33 @@ def test_generate_server_name(): def test_build_server_config(): """Test server configuration building with auth header.""" - config = _build_server_config("https://example.com/mcp", "http") + config = _build_server_config("https://example.com/mcp", "http", api_key="test-key") assert config == { "url": "https://example.com/mcp", "transport": "http", "headers": { - "Authorization": "Bearer ${MCP_API_KEY}" + "Authorization": "Bearer test-key" } } - config_sse = _build_server_config("https://example.com/sse", "sse") + config_sse = _build_server_config("https://example.com/sse", "sse", api_key="test-key") assert config_sse == { "url": "https://example.com/sse", "transport": "sse", "headers": { - "Authorization": "Bearer ${MCP_API_KEY}" + "Authorization": "Bearer test-key" } } - # Claude Desktop uses mcp-remote wrapper - config_claude = _build_server_config("https://example.com/sse", "sse", for_claude_desktop=True) + # Claude Desktop uses mcp-remote wrapper with actual API key + config_claude = _build_server_config("https://example.com/sse", "sse", for_claude_desktop=True, api_key="test-api-key-123") assert config_claude == { "command": "npx", "args": [ "mcp-remote", "https://example.com/sse", "--header", - "Authorization: Bearer ${MCP_API_KEY}" + "Authorization: Bearer test-api-key-123" ] } @@ -86,7 +86,7 @@ def test_merge_mcp_json_empty(): result = _merge_mcp_json({}, "test-server", { "url": "https://example.com", "transport": "http", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} }) assert result == { "mcp": { @@ -94,7 +94,7 @@ def test_merge_mcp_json_empty(): "test-server": { "url": "https://example.com", "transport": "http", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} } } } @@ -106,14 +106,14 @@ def test_merge_mcp_json_claude_format(): result = _merge_mcp_json({}, "test-server", { "url": "https://example.com", "transport": "http", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} }, use_claude_format=True) assert result == { "mcpServers": { "test-server": { "url": "https://example.com", "transport": "http", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} } } } @@ -134,7 +134,7 @@ def test_merge_mcp_json_existing(): result = _merge_mcp_json( existing, "new-server", - {"url": "https://new.com", "transport": "http", "headers": {"Authorization": "Bearer ${MCP_API_KEY}"}}, + {"url": "https://new.com", "transport": "http", "headers": {"Authorization": "Bearer test-key"}}, ) assert result == { "mcp": { @@ -146,7 +146,7 @@ def test_merge_mcp_json_existing(): "new-server": { "url": "https://new.com", "transport": "http", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} }, } } @@ -168,7 +168,7 @@ def test_merge_mcp_json_overwrite(): result = _merge_mcp_json( existing, "test-server", - {"url": "https://new.com", "transport": "sse", "headers": {"Authorization": "Bearer ${MCP_API_KEY}"}}, + {"url": "https://new.com", "transport": "sse", "headers": {"Authorization": "Bearer test-key"}}, ) assert result == { "mcp": { @@ -176,7 +176,7 @@ def test_merge_mcp_json_overwrite(): "test-server": { "url": "https://new.com", "transport": "sse", - "headers": {"Authorization": "Bearer ${MCP_API_KEY}"} + "headers": {"Authorization": "Bearer test-key"} } } } @@ -271,7 +271,7 @@ def test_install_vscode(tmp_path): server = config["mcp"]["servers"]["test-server"] assert server["url"] == MOCK_APP_SERVER_URL assert server["transport"] == "sse" - assert server["headers"]["Authorization"] == "Bearer ${MCP_API_KEY}" + assert server["headers"]["Authorization"] == "Bearer test-key" def test_install_cursor_with_existing_config(tmp_path): From f0f1f0c80df3ef5496e5c226c818085f20f3ed82 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:42:26 +0000 Subject: [PATCH 06/10] fix claude code --- src/mcp_agent/cli/commands/install.py | 68 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 2c0acc4b0..93e04c418 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -9,7 +9,7 @@ Supported clients: - vscode: writes .vscode/mcp.json in project - - claude_code: writes ~/.claude/claude_code_config.json + - claude_code: writes ~/.claude.json (uses "type" field instead of "transport") - cursor: writes ~/.cursor/mcp.json - claude_desktop: writes platform-specific Claude Desktop config using mcp-remote wrapper - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json @@ -64,7 +64,7 @@ def _get_claude_desktop_config_path() -> Path: "description": "VSCode (project-local)", }, "claude_code": { - "path": lambda: Path.home() / ".claude" / "claude_code_config.json", + "path": lambda: Path.home() / ".claude.json", "description": "Claude Code", }, "cursor": { @@ -120,16 +120,18 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") -def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, api_key: str = None) -> dict: +def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, for_claude_code: bool = False, api_key: str = None) -> dict: """Build server configuration dictionary with auth header. For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper with actual API key. + For Claude Code, uses "type" field instead of "transport". For other clients, uses direct HTTP/SSE connection with actual API key embedded. Args: server_url: The server URL transport: Transport type (http or sse) for_claude_desktop: Whether to use Claude Desktop format with mcp-remote + for_claude_code: Whether to use Claude Code format with "type" field api_key: The actual API key (required for all clients) """ if not api_key: @@ -146,6 +148,15 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de f"Authorization: Bearer {api_key}" ] } + elif for_claude_code: + # Claude Code uses "type" instead of "transport" + return { + "type": transport, + "url": server_url, + "headers": { + "Authorization": f"Bearer {api_key}" + } + } else: # Direct HTTP/SSE connection for other clients with embedded API key return { @@ -340,6 +351,38 @@ def install( ) return + # Use app name from API, fallback to custom name or generated name + server_name = name or app_name or _generate_server_name(server_url) + + # For Claude Code, use the `claude mcp add` command instead of editing JSON + if client_lc == "claude_code": + if dry_run: + console.print(f"\n[bold yellow]DRY RUN - Would run:[/bold yellow]") + console.print(f"claude mcp add {server_name} {server_url} -t sse -H 'Authorization: Bearer ' -s user") + return + + import subprocess + try: + cmd = [ + "claude", "mcp", "add", + server_name, + server_url, + "-t", "sse", + "-H", f"Authorization: Bearer {effective_api_key}", + "-s", "user" + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + print_success(f"Server '{server_name}' installed to Claude Code") + console.print(result.stdout) + return + except subprocess.CalledProcessError as e: + raise CLIError(f"Failed to add server to Claude Code: {e.stderr}") from e + except FileNotFoundError: + raise CLIError( + "Claude Code CLI not found. Make sure 'claude' command is available in your PATH.\n" + "Install from: https://docs.claude.com/en/docs/claude-code" + ) + # For other clients, write to config file if dry_run: print_info("[bold yellow]DRY RUN - No files will be written[/bold yellow]") @@ -347,11 +390,9 @@ def install( client_config = CLIENT_CONFIGS[client_lc] config_path = client_config["path"]() - # Use app name from API, fallback to custom name or generated name - server_name = name or app_name or _generate_server_name(server_url) - - # Determine if we're using Claude Desktop format + # Determine config format based on client use_claude_format = client_lc == "claude_desktop" + use_claude_code_format = client_lc == "claude_code" # Check existing config existing_config = {} @@ -359,7 +400,7 @@ def install( try: existing_config = json.loads(config_path.read_text(encoding="utf-8")) # Check in appropriate location based on format - if use_claude_format: + if use_claude_format or use_claude_code_format: servers = existing_config.get("mcpServers", {}) else: servers = existing_config.get("mcp", {}).get("servers", {}) @@ -379,11 +420,13 @@ def install( server_url, transport, for_claude_desktop=use_claude_format, + for_claude_code=use_claude_code_format, api_key=effective_api_key ) - # Merge with existing config - merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_claude_format) + # Merge with existing config (Claude Code and Claude Desktop both use mcpServers format) + use_mcp_servers_format = use_claude_format or use_claude_code_format + merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_mcp_servers_format) # Write or show config if dry_run: @@ -403,6 +446,11 @@ def install( f"[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" f"[dim]API key embedded in config[/dim]" ) + elif use_claude_code_format: + auth_note = ( + f"[bold]Note:[/bold] Claude Code format uses [cyan]type[/cyan] field\n" + f"[dim]API key embedded in config. To update, re-run install with --force[/dim]" + ) else: auth_note = ( f"[bold]Authentication:[/bold] API key embedded in config\n" From 95cba44bb03e2137b44dd581393086658821fe6c Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:55:33 +0000 Subject: [PATCH 07/10] vscode working --- src/mcp_agent/cli/commands/install.py | 83 ++++++++++++++++++--------- tests/cli/commands/test_install.py | 81 +++++++++++++++----------- 2 files changed, 103 insertions(+), 61 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 93e04c418..59287064f 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -78,7 +78,7 @@ def _get_claude_desktop_config_path() -> Path: } -def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, use_claude_format: bool = False) -> dict: +def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, format_type: str = "mcp") -> dict: """ Merge a server configuration into existing MCP JSON. @@ -86,30 +86,48 @@ def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, use_c existing: Existing config dict server_name: Name of the server to add/update server_config: Server configuration dict - use_claude_format: If True, use Claude Desktop format {"mcpServers": {...}} - If False, use standard format {"mcp": {"servers": {...}}} + format_type: Format to use: + - "mcpServers" for Claude Desktop/Claude Code + - "vscode" for VSCode (top-level "servers" + "inputs") + - "mcp" for Cursor (standard {"mcp": {"servers": {...}}}) """ servers: dict = {} + other_keys: dict = {} + if isinstance(existing, dict): - # Check for Claude Desktop format first + # Check for Claude Desktop/Code format if "mcpServers" in existing and isinstance(existing.get("mcpServers"), dict): servers = dict(existing["mcpServers"]) + # Check for VSCode format + elif "servers" in existing and isinstance(existing.get("servers"), dict): + servers = dict(existing["servers"]) + # Preserve other VSCode keys like "inputs" + for k, v in existing.items(): + if k != "servers": + other_keys[k] = v + # Check for standard MCP format elif "mcp" in existing and isinstance(existing.get("mcp"), dict): servers = dict(existing["mcp"].get("servers") or {}) - elif "servers" in existing and isinstance(existing.get("servers"), dict): - servers = dict(existing.get("servers") or {}) else: # Treat top-level mapping as servers if it looks like name->obj for k, v in existing.items(): - if isinstance(v, dict) and ("url" in v or "transport" in v or "command" in v): + if isinstance(v, dict) and ("url" in v or "transport" in v or "command" in v or "type" in v): servers[k] = v # Add/update the new server servers[server_name] = server_config # Return in appropriate format - if use_claude_format: + if format_type == "mcpServers": return {"mcpServers": servers} + elif format_type == "vscode": + result = {"servers": servers} + # Add inputs array if not present + if "inputs" not in other_keys: + result["inputs"] = [] + # Merge in other keys + result.update(other_keys) + return result else: return {"mcp": {"servers": servers}} @@ -120,18 +138,18 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") -def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, for_claude_code: bool = False, api_key: str = None) -> dict: +def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, for_vscode: bool = False, api_key: str = None) -> dict: """Build server configuration dictionary with auth header. For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper with actual API key. - For Claude Code, uses "type" field instead of "transport". - For other clients, uses direct HTTP/SSE connection with actual API key embedded. + For VSCode, uses "type" field and top-level "servers" structure. + For other clients (Cursor), uses "transport" field with "mcp.servers" structure. Args: server_url: The server URL transport: Transport type (http or sse) for_claude_desktop: Whether to use Claude Desktop format with mcp-remote - for_claude_code: Whether to use Claude Code format with "type" field + for_vscode: Whether to use VSCode format with "type" field api_key: The actual API key (required for all clients) """ if not api_key: @@ -148,8 +166,8 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de f"Authorization: Bearer {api_key}" ] } - elif for_claude_code: - # Claude Code uses "type" instead of "transport" + elif for_vscode: + # VSCode uses "type" instead of "transport" return { "type": transport, "url": server_url, @@ -158,7 +176,7 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de } } else: - # Direct HTTP/SSE connection for other clients with embedded API key + # Direct HTTP/SSE connection for Cursor with embedded API key return { "url": server_url, "transport": transport, @@ -391,8 +409,9 @@ def install( config_path = client_config["path"]() # Determine config format based on client - use_claude_format = client_lc == "claude_desktop" - use_claude_code_format = client_lc == "claude_code" + is_vscode = client_lc == "vscode" + is_claude_desktop = client_lc == "claude_desktop" + is_cursor = client_lc == "cursor" # Check existing config existing_config = {} @@ -400,9 +419,11 @@ def install( try: existing_config = json.loads(config_path.read_text(encoding="utf-8")) # Check in appropriate location based on format - if use_claude_format or use_claude_code_format: + if is_claude_desktop: servers = existing_config.get("mcpServers", {}) - else: + elif is_vscode: + servers = existing_config.get("servers", {}) + else: # cursor servers = existing_config.get("mcp", {}).get("servers", {}) if server_name in servers and not force: @@ -415,18 +436,24 @@ def install( # Determine transport type transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - # Build server config with embedded API key (all clients) + # Build server config with embedded API key server_config = _build_server_config( server_url, transport, - for_claude_desktop=use_claude_format, - for_claude_code=use_claude_code_format, + for_claude_desktop=is_claude_desktop, + for_vscode=is_vscode, api_key=effective_api_key ) - # Merge with existing config (Claude Code and Claude Desktop both use mcpServers format) - use_mcp_servers_format = use_claude_format or use_claude_code_format - merged_config = _merge_mcp_json(existing_config, server_name, server_config, use_mcp_servers_format) + # Determine merge format + if is_claude_desktop: + format_type = "mcpServers" + elif is_vscode: + format_type = "vscode" + else: # cursor + format_type = "mcp" + + merged_config = _merge_mcp_json(existing_config, server_name, server_config, format_type) # Write or show config if dry_run: @@ -441,14 +468,14 @@ def install( raise CLIError(f"Failed to write config file: {e}") from e # Success message - if use_claude_format: + if is_claude_desktop: auth_note = ( f"[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" f"[dim]API key embedded in config[/dim]" ) - elif use_claude_code_format: + elif is_vscode: auth_note = ( - f"[bold]Note:[/bold] Claude Code format uses [cyan]type[/cyan] field\n" + f"[bold]Note:[/bold] VSCode uses [cyan]type: sse[/cyan] format\n" f"[dim]API key embedded in config. To update, re-run install with --force[/dim]" ) else: diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index b5f8bfe06..bb3aeca7b 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -104,18 +104,35 @@ def test_merge_mcp_json_empty(): def test_merge_mcp_json_claude_format(): """Test merging with Claude Desktop format.""" result = _merge_mcp_json({}, "test-server", { + "command": "npx", + "args": ["mcp-remote", "https://example.com/sse"] + }, format_type="mcpServers") + assert result == { + "mcpServers": { + "test-server": { + "command": "npx", + "args": ["mcp-remote", "https://example.com/sse"] + } + } + } + + +def test_merge_mcp_json_vscode_format(): + """Test merging with VSCode format.""" + result = _merge_mcp_json({}, "test-server", { + "type": "sse", "url": "https://example.com", - "transport": "http", "headers": {"Authorization": "Bearer test-key"} - }, use_claude_format=True) + }, format_type="vscode") assert result == { - "mcpServers": { + "servers": { "test-server": { + "type": "sse", "url": "https://example.com", - "transport": "http", "headers": {"Authorization": "Bearer test-key"} } - } + }, + "inputs": [] } @@ -263,14 +280,14 @@ def test_install_vscode(tmp_path): # Verify config file was created assert vscode_config.exists() - # Verify config contents + # Verify config contents (VSCode format) config = json.loads(vscode_config.read_text()) - assert "mcp" in config - assert "servers" in config["mcp"] - assert "test-server" in config["mcp"]["servers"] - server = config["mcp"]["servers"]["test-server"] + assert "servers" in config + assert "inputs" in config + assert "test-server" in config["servers"] + server = config["servers"]["test-server"] assert server["url"] == MOCK_APP_SERVER_URL - assert server["transport"] == "sse" + assert server["type"] == "sse" assert server["headers"]["Authorization"] == "Bearer test-key" @@ -320,16 +337,15 @@ def test_install_duplicate_without_force(tmp_path): vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) - # Create existing config with same server name + # Create existing config with same server name (VSCode format) existing = { - "mcp": { - "servers": { - "test-server": { - "url": "https://old.com/mcp", - "transport": "http", - } + "servers": { + "test-server": { + "url": "https://old.com/mcp", + "type": "http", } - } + }, + "inputs": [] } vscode_config.write_text(json.dumps(existing, indent=2)) @@ -356,16 +372,15 @@ def test_install_duplicate_with_force(tmp_path): vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) - # Create existing config with same server name + # Create existing config with same server name (VSCode format) existing = { - "mcp": { - "servers": { - "test-server": { - "url": "https://old.com/mcp", - "transport": "http", - } + "servers": { + "test-server": { + "url": "https://old.com/mcp", + "type": "http", } - } + }, + "inputs": [] } vscode_config.write_text(json.dumps(existing, indent=2)) @@ -385,9 +400,9 @@ def test_install_duplicate_with_force(tmp_path): api_key="test-key", ) - # Verify config was updated + # Verify config was updated (VSCode format) config = json.loads(vscode_config.read_text()) - assert config["mcp"]["servers"]["test-server"]["url"] == MOCK_APP_SERVER_URL + assert config["servers"]["test-server"]["url"] == MOCK_APP_SERVER_URL def test_install_chatgpt_requires_unauth_access(mock_app_with_auth): @@ -485,9 +500,9 @@ def test_install_sse_transport_detection(tmp_path): api_key="test-key", ) - # Verify SSE transport was used + # Verify SSE type was used (VSCode format) config = json.loads(vscode_config.read_text()) - assert config["mcp"]["servers"]["test-server"]["transport"] == "sse" + assert config["servers"]["test-server"]["type"] == "sse" def test_install_http_transport_detection(tmp_path): @@ -510,6 +525,6 @@ def test_install_http_transport_detection(tmp_path): api_key="test-key", ) - # Verify HTTP transport was used + # Verify HTTP type was used (VSCode format) config = json.loads(vscode_config.read_text()) - assert config["mcp"]["servers"]["test-server"]["transport"] == "http" + assert config["servers"]["test-server"]["type"] == "http" From d6548328516ed6fe28f5d92d45c4fe4c396e52e7 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Wed, 22 Oct 2025 00:17:41 +0000 Subject: [PATCH 08/10] fix cursor and tests --- src/mcp_agent/cli/commands/install.py | 82 ++++++--------------------- tests/cli/commands/test_install.py | 35 +++--------- 2 files changed, 25 insertions(+), 92 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 59287064f..0538a98ce 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -8,10 +8,10 @@ For ChatGPT, the server must have unauthenticated access enabled. Supported clients: - - vscode: writes .vscode/mcp.json in project - - claude_code: writes ~/.claude.json (uses "type" field instead of "transport") + - vscode: writes .vscode/mcp.json + - claude_code: writes ~/.claude.json - cursor: writes ~/.cursor/mcp.json - - claude_desktop: writes platform-specific Claude Desktop config using mcp-remote wrapper + - claude_desktop: writes platform-specific config using mcp-remote wrapper - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Windows: ~/AppData/Roaming/Claude/claude_desktop_config.json - Linux: ~/.config/Claude/claude_desktop_config.json @@ -95,37 +95,28 @@ def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, forma other_keys: dict = {} if isinstance(existing, dict): - # Check for Claude Desktop/Code format if "mcpServers" in existing and isinstance(existing.get("mcpServers"), dict): servers = dict(existing["mcpServers"]) - # Check for VSCode format elif "servers" in existing and isinstance(existing.get("servers"), dict): servers = dict(existing["servers"]) - # Preserve other VSCode keys like "inputs" for k, v in existing.items(): if k != "servers": other_keys[k] = v - # Check for standard MCP format elif "mcp" in existing and isinstance(existing.get("mcp"), dict): servers = dict(existing["mcp"].get("servers") or {}) else: - # Treat top-level mapping as servers if it looks like name->obj for k, v in existing.items(): if isinstance(v, dict) and ("url" in v or "transport" in v or "command" in v or "type" in v): servers[k] = v - # Add/update the new server servers[server_name] = server_config - # Return in appropriate format if format_type == "mcpServers": return {"mcpServers": servers} elif format_type == "vscode": result = {"servers": servers} - # Add inputs array if not present if "inputs" not in other_keys: result["inputs"] = [] - # Merge in other keys result.update(other_keys) return result else: @@ -186,32 +177,6 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de } -def _generate_server_name(server_url: str) -> str: - """Generate a server name from URL.""" - # Extract meaningful part from URL - # e.g., https://api.example.com/servers/my-server/mcp -> my-server - parts = server_url.rstrip("/").split("/") - - # If URL has path segments (more than protocol://domain) - if len(parts) > 3: # ['https:', '', 'domain', 'path', ...] - # Try to get the last meaningful part before /mcp or /sse - path_parts = [p for p in parts[3:] if p and p not in ('mcp', 'sse')] - if path_parts: - return path_parts[-1] - - # Fall back to domain name - if len(parts) >= 3: - # Extract domain from parts[2] (after https://) - domain = parts[2] - # Remove port if present, extract subdomain - domain = domain.split(':')[0] - # Use first part of subdomain for cleaner name - subdomain = domain.split('.')[0] - return subdomain - - return "server" - - @app.callback(invoke_without_command=True) def install( server_identifier: str = typer.Argument( @@ -266,28 +231,24 @@ def install( """ client_lc = client.lower() - # Validate client if client_lc not in CLIENT_CONFIGS and client_lc != "chatgpt": raise CLIError( f"Unsupported client: {client}. Supported clients: vscode, claude_code, cursor, claude_desktop, chatgpt" ) - # Authenticate effective_api_key = api_key or settings.API_KEY or load_api_key_credentials() if not effective_api_key: raise CLIError( "Must be logged in to install. Run 'mcp-agent login', set MCP_API_KEY environment variable, or specify --api-key option." ) - # Normalize server URL server_url = server_identifier if not server_identifier.startswith("http://") and not server_identifier.startswith("https://"): raise CLIError( f"Server identifier must be a URL starting with http:// or https://. Got: {server_identifier}" ) - # Ensure URL ends with /sse for MCP Agent Cloud deployments if not server_url.endswith("/sse") and not server_url.endswith("/mcp"): server_url = server_url.rstrip("/") + "/sse" print_info(f"Using SSE transport: {server_url}") @@ -296,7 +257,6 @@ def install( print_info(f"Server URL: {server_url}") print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") - # Fetch app info to get the actual app name mcp_client = MCPAppClient( api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key ) @@ -312,9 +272,7 @@ def install( # For ChatGPT, check if server has unauthenticated access enabled if client_lc == "chatgpt": try: - # Check unauthenticated access from app_info we already fetched if not app_info: - # Try to fetch again if we don't have it app_info = run_async(mcp_client.get_app(server_url=server_url)) has_unauth_access = ( @@ -344,14 +302,11 @@ def install( raise typer.Exit(1) except typer.Exit: - # Re-raise typer.Exit to properly exit raise except Exception as e: - # If we can't fetch app info, warn but continue print_info(f"Warning: Could not verify unauthenticated access: {e}") print_info("Proceeding with installation, but ChatGPT may not be able to connect.") - # Show ChatGPT instructions console.print( Panel( f"[bold]ChatGPT Setup Instructions[/bold]\n\n" @@ -369,8 +324,7 @@ def install( ) return - # Use app name from API, fallback to custom name or generated name - server_name = name or app_name or _generate_server_name(server_url) + server_name = name or app_name or server_url # For Claude Code, use the `claude mcp add` command instead of editing JSON if client_lc == "claude_code": @@ -401,29 +355,25 @@ def install( "Install from: https://docs.claude.com/en/docs/claude-code" ) - # For other clients, write to config file if dry_run: print_info("[bold yellow]DRY RUN - No files will be written[/bold yellow]") client_config = CLIENT_CONFIGS[client_lc] config_path = client_config["path"]() - # Determine config format based on client is_vscode = client_lc == "vscode" is_claude_desktop = client_lc == "claude_desktop" is_cursor = client_lc == "cursor" - # Check existing config existing_config = {} if config_path.exists(): try: existing_config = json.loads(config_path.read_text(encoding="utf-8")) - # Check in appropriate location based on format - if is_claude_desktop: + if is_claude_desktop or is_cursor: servers = existing_config.get("mcpServers", {}) elif is_vscode: servers = existing_config.get("servers", {}) - else: # cursor + else: servers = existing_config.get("mcp", {}).get("servers", {}) if server_name in servers and not force: @@ -433,10 +383,8 @@ def install( except json.JSONDecodeError as e: raise CLIError(f"Failed to parse existing config at {config_path}: {e}") from e - # Determine transport type transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - # Build server config with embedded API key server_config = _build_server_config( server_url, transport, @@ -445,17 +393,15 @@ def install( api_key=effective_api_key ) - # Determine merge format - if is_claude_desktop: + if is_claude_desktop or is_cursor: format_type = "mcpServers" elif is_vscode: format_type = "vscode" - else: # cursor + else: format_type = "mcp" merged_config = _merge_mcp_json(existing_config, server_name, server_config, format_type) - # Write or show config if dry_run: console.print("\n[bold]Would write to:[/bold]", config_path) console.print("\n[bold]Config:[/bold]") @@ -467,16 +413,20 @@ def install( except Exception as e: raise CLIError(f"Failed to write config file: {e}") from e - # Success message if is_claude_desktop: auth_note = ( f"[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" - f"[dim]API key embedded in config[/dim]" + f"[dim]API key embedded in config. Restart Claude Desktop to load the server.[/dim]" ) elif is_vscode: auth_note = ( - f"[bold]Note:[/bold] VSCode uses [cyan]type: sse[/cyan] format\n" - f"[dim]API key embedded in config. To update, re-run install with --force[/dim]" + f"[bold]Note:[/bold] VSCode format uses [cyan]type: {transport}[/cyan]\n" + f"[dim]API key embedded. Restart VSCode to load the server.[/dim]" + ) + elif is_cursor: + auth_note = ( + f"[bold]Note:[/bold] Cursor format uses [cyan]transport: {transport}[/cyan]\n" + f"[dim]API key embedded. Restart Cursor to load the server.[/dim]" ) else: auth_note = ( diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index bb3aeca7b..67901d42f 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -7,7 +7,6 @@ import pytest from mcp_agent.cli.commands.install import ( _build_server_config, - _generate_server_name, _merge_mcp_json, install, ) @@ -22,6 +21,7 @@ def mock_app_with_auth(): """Create a mock app that requires authentication.""" app = MagicMock() app.appId = "app-123" + app.name = "test-app" app.unauthenticatedAccess = False app.appServerInfo = MagicMock() app.appServerInfo.serverUrl = MOCK_APP_SERVER_URL @@ -34,6 +34,7 @@ def mock_app_without_auth(): """Create a mock app with unauthenticated access.""" app = MagicMock() app.appId = "app-456" + app.name = "test-app-public" app.unauthenticatedAccess = True app.appServerInfo = MagicMock() app.appServerInfo.serverUrl = MOCK_APP_SERVER_URL @@ -41,13 +42,6 @@ def mock_app_without_auth(): return app -def test_generate_server_name(): - """Test server name generation from URLs.""" - assert _generate_server_name("https://z53gajrsdkssfgjmgaka1i27crthugq.deployments.mcp-agent.com/sse") == "z53gajrsdkssfgjmgaka1i27crthugq" - assert _generate_server_name("https://api.example.com/servers/my-server/mcp") == "my-server" - assert _generate_server_name("https://example.com") == "example" - - def test_build_server_config(): """Test server configuration building with auth header.""" config = _build_server_config("https://example.com/mcp", "http", api_key="test-key") @@ -296,14 +290,11 @@ def test_install_cursor_with_existing_config(tmp_path): cursor_config = tmp_path / ".cursor" / "mcp.json" cursor_config.parent.mkdir(parents=True, exist_ok=True) - # Create existing config existing = { - "mcp": { - "servers": { - "existing-server": { - "url": "https://existing.com/mcp", - "transport": "http", - } + "mcpServers": { + "existing-server": { + "url": "https://existing.com/mcp", + "transport": "http", } } } @@ -325,11 +316,10 @@ def test_install_cursor_with_existing_config(tmp_path): api_key="test-key", ) - # Verify config file was updated config = json.loads(cursor_config.read_text()) - assert len(config["mcp"]["servers"]) == 2 - assert "existing-server" in config["mcp"]["servers"] - assert "new-server" in config["mcp"]["servers"] + assert len(config["mcpServers"]) == 2 + assert "existing-server" in config["mcpServers"] + assert "new-server" in config["mcpServers"] def test_install_duplicate_without_force(tmp_path): @@ -337,7 +327,6 @@ def test_install_duplicate_without_force(tmp_path): vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) - # Create existing config with same server name (VSCode format) existing = { "servers": { "test-server": { @@ -372,7 +361,6 @@ def test_install_duplicate_with_force(tmp_path): vscode_config = tmp_path / ".vscode" / "mcp.json" vscode_config.parent.mkdir(parents=True, exist_ok=True) - # Create existing config with same server name (VSCode format) existing = { "servers": { "test-server": { @@ -400,7 +388,6 @@ def test_install_duplicate_with_force(tmp_path): api_key="test-key", ) - # Verify config was updated (VSCode format) config = json.loads(vscode_config.read_text()) assert config["servers"]["test-server"]["url"] == MOCK_APP_SERVER_URL @@ -445,7 +432,6 @@ def test_install_chatgpt_with_unauth_server(mock_app_without_auth): mock_client.get_app = AsyncMock(return_value=mock_app_without_auth) mock_client_class.return_value = mock_client - # Should not raise, just print instructions install( server_identifier=MOCK_APP_SERVER_URL, client="chatgpt", @@ -475,7 +461,6 @@ def test_install_dry_run(tmp_path, capsys): api_key="test-key", ) - # Verify no files were written vscode_config = tmp_path / ".vscode" / "mcp.json" assert not vscode_config.exists() @@ -500,7 +485,6 @@ def test_install_sse_transport_detection(tmp_path): api_key="test-key", ) - # Verify SSE type was used (VSCode format) config = json.loads(vscode_config.read_text()) assert config["servers"]["test-server"]["type"] == "sse" @@ -525,6 +509,5 @@ def test_install_http_transport_detection(tmp_path): api_key="test-key", ) - # Verify HTTP type was used (VSCode format) config = json.loads(vscode_config.read_text()) assert config["servers"]["test-server"]["type"] == "http" From 20d5021051789bf5d95fa59df68103f50e4aefc7 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Wed, 22 Oct 2025 00:20:12 +0000 Subject: [PATCH 09/10] formatting --- src/mcp_agent/cli/commands/install.py | 16 ++++++++-------- tests/cli/commands/test_install.py | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 0538a98ce..ae65e0b4f 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -253,7 +253,7 @@ def install( server_url = server_url.rstrip("/") + "/sse" print_info(f"Using SSE transport: {server_url}") - console.print(f"\n[bold cyan]Installing MCP Server[/bold cyan]\n") + console.print("\n[bold cyan]Installing MCP Server[/bold cyan]\n") print_info(f"Server URL: {server_url}") print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") @@ -276,8 +276,8 @@ def install( app_info = run_async(mcp_client.get_app(server_url=server_url)) has_unauth_access = ( - app_info.unauthenticatedAccess == True or - (app_info.appServerInfo and app_info.appServerInfo.unauthenticatedAccess == True) + app_info.unauthenticatedAccess is True or + (app_info.appServerInfo and app_info.appServerInfo.unauthenticatedAccess is True) ) if not has_unauth_access: @@ -329,7 +329,7 @@ def install( # For Claude Code, use the `claude mcp add` command instead of editing JSON if client_lc == "claude_code": if dry_run: - console.print(f"\n[bold yellow]DRY RUN - Would run:[/bold yellow]") + console.print("\n[bold yellow]DRY RUN - Would run:[/bold yellow]") console.print(f"claude mcp add {server_name} {server_url} -t sse -H 'Authorization: Bearer ' -s user") return @@ -415,8 +415,8 @@ def install( if is_claude_desktop: auth_note = ( - f"[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" - f"[dim]API key embedded in config. Restart Claude Desktop to load the server.[/dim]" + "[bold]Note:[/bold] Claude Desktop uses [cyan]mcp-remote[/cyan] to connect to HTTP/SSE servers\n" + "[dim]API key embedded in config. Restart Claude Desktop to load the server.[/dim]" ) elif is_vscode: auth_note = ( @@ -430,8 +430,8 @@ def install( ) else: auth_note = ( - f"[bold]Authentication:[/bold] API key embedded in config\n" - f"[dim]To update the key, re-run install with --force[/dim]" + "[bold]Authentication:[/bold] API key embedded in config\n" + "[dim]To update the key, re-run install with --force[/dim]" ) console.print( diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index 67901d42f..d2850efa5 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -1,7 +1,6 @@ """Tests for the install command.""" import json -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest From 88f7274084171ec423021d1da6bd43fc8b3024ff Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Wed, 22 Oct 2025 00:45:14 +0000 Subject: [PATCH 10/10] PR feedback --- src/mcp_agent/cli/commands/install.py | 108 +++++++++++++++++++++----- tests/cli/commands/test_install.py | 21 +++++ 2 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index ae65e0b4f..07e7e20d0 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -9,7 +9,7 @@ Supported clients: - vscode: writes .vscode/mcp.json - - claude_code: writes ~/.claude.json + - claude_code: integrated via 'claude mcp add' - cursor: writes ~/.cursor/mcp.json - claude_desktop: writes platform-specific config using mcp-remote wrapper - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json @@ -21,9 +21,14 @@ from __future__ import annotations import json +import os import platform +import subprocess +import tempfile +from copy import deepcopy from pathlib import Path from typing import Optional +from urllib.parse import urlparse import typer from rich.panel import Panel @@ -87,9 +92,9 @@ def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, forma server_name: Name of the server to add/update server_config: Server configuration dict format_type: Format to use: - - "mcpServers" for Claude Desktop/Claude Code - - "vscode" for VSCode (top-level "servers" + "inputs") - - "mcp" for Cursor (standard {"mcp": {"servers": {...}}}) + - "mcpServers" for Claude Desktop/Cursor + - "vscode" for VSCode + - "mcp" for other clients """ servers: dict = {} other_keys: dict = {} @@ -123,10 +128,79 @@ def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, forma return {"mcp": {"servers": servers}} +def _redact_secrets(data: dict) -> dict: + """Mask Authorization values and mcp-remote header args for safe display.""" + red = deepcopy(data) + + def walk(obj): + if isinstance(obj, dict): + for k, v in obj.items(): + if k.lower() == "authorization" and isinstance(v, str): + obj[k] = "Bearer ***" + else: + walk(v) + elif isinstance(obj, list): + for i, v in enumerate(obj): + if isinstance(v, str) and v.lower().startswith("authorization: bearer "): + obj[i] = "Authorization: Bearer ***" + else: + walk(v) + + walk(red) + return red + + def _write_json(path: Path, data: dict) -> None: - """Write JSON data to file, creating parent directories as needed.""" + """Write JSON atomically and restrict permissions (secrets inside).""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + original_mode = None + if path.exists() and os.name == "posix": + original_mode = os.stat(path).st_mode & 0o777 + + tmp_fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + f.write(json.dumps(data, indent=2)) + os.replace(tmp_name, path) # atomic on same fs + if os.name == "posix": + os.chmod(path, original_mode if original_mode is not None else 0o600) + finally: + try: + if os.path.exists(tmp_name): + os.remove(tmp_name) + except Exception: + pass + + +def _server_hostname(server_url: str, app_name: Optional[str] = None) -> str: + """ + Generate a friendly server name from the URL. + + Extracts the subdomain or hostname to create a short, readable name. + For example, "https://abc123.deployments.mcp-agent.com/sse" -> "abc123" + + Args: + server_url: The server URL + app_name: Optional app name from API (preferred if available) + + Returns: + A friendly server name + """ + if app_name: + return app_name + + parsed = urlparse(server_url) + hostname = parsed.hostname or "" + + parts = hostname.split(".") + if len(parts) > 2 and "deployments" in hostname: + return parts[0] + + if len(parts) >= 2: + return ".".join(parts[:-1]) + + return hostname or "mcp-server" def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, for_vscode: bool = False, api_key: str = None) -> dict: @@ -134,7 +208,7 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper with actual API key. For VSCode, uses "type" field and top-level "servers" structure. - For other clients (Cursor), uses "transport" field with "mcp.servers" structure. + For other clients (Cursor), uses "transport" field with "mcpServers" top-level structure. Args: server_url: The server URL @@ -180,7 +254,7 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de @app.callback(invoke_without_command=True) def install( server_identifier: str = typer.Argument( - ..., help="Server URL or app ID to install" + ..., help="Server URL to install" ), client: str = typer.Option( ..., "--client", "-c", help="Client to install to: vscode|claude_code|cursor|claude_desktop|chatgpt" @@ -315,7 +389,7 @@ def install( f"3. Enable developer mode under advanced settings\n" f"4. Select create on the top right corner of the panel\n" f"5. Add a new server:\n" - f" • URL: [cyan]{server_url}/sse[/cyan]\n" + f" • URL: [cyan]{server_url}[/cyan]\n" f" • Transport: [cyan]sse[/cyan]\n\n" f"[dim]Note: This server has unauthenticated access enabled.[/dim]", title="ChatGPT Configuration", @@ -324,26 +398,26 @@ def install( ) return - server_name = name or app_name or server_url + server_name = name or _server_hostname(server_url, app_name) + + transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - # For Claude Code, use the `claude mcp add` command instead of editing JSON if client_lc == "claude_code": if dry_run: console.print("\n[bold yellow]DRY RUN - Would run:[/bold yellow]") - console.print(f"claude mcp add {server_name} {server_url} -t sse -H 'Authorization: Bearer ' -s user") + console.print(f"claude mcp add {server_name} {server_url} -t {transport} -H 'Authorization: Bearer ' -s user") return - import subprocess try: cmd = [ "claude", "mcp", "add", server_name, server_url, - "-t", "sse", + "-t", transport, "-H", f"Authorization: Bearer {effective_api_key}", "-s", "user" ] - result = subprocess.run(cmd, capture_output=True, text=True, check=True) + result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30) print_success(f"Server '{server_name}' installed to Claude Code") console.print(result.stdout) return @@ -383,8 +457,6 @@ def install( except json.JSONDecodeError as e: raise CLIError(f"Failed to parse existing config at {config_path}: {e}") from e - transport = "sse" if server_url.rstrip("/").endswith("/sse") else "http" - server_config = _build_server_config( server_url, transport, @@ -405,7 +477,7 @@ def install( if dry_run: console.print("\n[bold]Would write to:[/bold]", config_path) console.print("\n[bold]Config:[/bold]") - console.print_json(data=merged_config) + console.print_json(data=_redact_secrets(merged_config)) else: try: _write_json(config_path, merged_config) diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index d2850efa5..46177881e 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -6,6 +6,7 @@ import pytest from mcp_agent.cli.commands.install import ( _build_server_config, + _server_hostname, _merge_mcp_json, install, ) @@ -41,6 +42,26 @@ def mock_app_without_auth(): return app +def test_server_hostname(): + """Test friendly server name generation from URLs.""" + # Test with deployment URL + assert _server_hostname("https://abc123.deployments.mcp-agent.com/sse") == "abc123" + assert _server_hostname("https://xyz456.deployments.example.com/mcp") == "xyz456" + + # Test with app name override + assert _server_hostname("https://abc123.deployments.mcp-agent.com/sse", "my-app") == "my-app" + + # Test with regular domain + assert _server_hostname("https://api.example.com/sse") == "api.example" + assert _server_hostname("https://subdomain.api.example.com/mcp") == "subdomain.api.example" + + # Test with simple domain + assert _server_hostname("https://example.com") == "example" + + # Test fallback + assert _server_hostname("invalid-url") == "mcp-server" + + def test_build_server_config(): """Test server configuration building with auth header.""" config = _build_server_config("https://example.com/mcp", "http", api_key="test-key")