diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index ac54de2..a80441e 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -659,6 +659,41 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): show_default=True, help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)", ) +@click.option( + "--nextcloud-host", + envvar="NEXTCLOUD_HOST", + help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)", +) +@click.option( + "--nextcloud-username", + envvar="NEXTCLOUD_USERNAME", + help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)", +) +@click.option( + "--nextcloud-password", + envvar="NEXTCLOUD_PASSWORD", + help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)", +) +@click.option( + "--oauth-scopes", + envvar="NEXTCLOUD_OIDC_SCOPES", + default="openid profile email nc:read nc:write", + show_default=True, + help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)", +) +@click.option( + "--oauth-token-type", + envvar="NEXTCLOUD_OIDC_TOKEN_TYPE", + default="bearer", + show_default=True, + type=click.Choice(["bearer", "jwt"], case_sensitive=False), + help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)", +) +@click.option( + "--public-issuer-url", + envvar="NEXTCLOUD_PUBLIC_ISSUER_URL", + help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)", +) def run( host: str, port: int, @@ -670,6 +705,12 @@ def run( oauth_client_secret: str | None, oauth_storage_path: str, mcp_server_url: str, + nextcloud_host: str | None, + nextcloud_username: str | None, + nextcloud_password: str | None, + oauth_scopes: str, + oauth_token_type: str, + public_issuer_url: str | None, ): """ Run the Nextcloud MCP server. @@ -681,24 +722,52 @@ def run( \b Examples: - # BasicAuth mode (legacy) + # BasicAuth mode with CLI options + $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\ + --nextcloud-username=admin --nextcloud-password=secret + + # BasicAuth mode with env vars (recommended for credentials) + $ export NEXTCLOUD_HOST=https://cloud.example.com + $ export NEXTCLOUD_USERNAME=admin + $ export NEXTCLOUD_PASSWORD=secret $ nextcloud-mcp-server --host 0.0.0.0 --port 8000 # OAuth mode with auto-registration - $ nextcloud-mcp-server --oauth + $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth # OAuth mode with pre-configured client - $ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy + $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\ + --oauth-client-id=xxx --oauth-client-secret=yyy + + # OAuth mode with custom scopes and JWT tokens + $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\ + --oauth-scopes="openid nc:read" --oauth-token-type=jwt + + # OAuth with public issuer URL (for Docker/proxy setups) + $ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\ + --public-issuer-url=http://localhost:8080 """ - # Set OAuth env vars from CLI options if provided + # Set env vars from CLI options if provided + if nextcloud_host: + os.environ["NEXTCLOUD_HOST"] = nextcloud_host + if nextcloud_username: + os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username + if nextcloud_password: + os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password if oauth_client_id: os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id if oauth_client_secret: os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret if oauth_storage_path: os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path + if oauth_scopes: + os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes + if oauth_token_type: + os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type if mcp_server_url: os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url + if public_issuer_url: + os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url # Force OAuth mode if explicitly requested if oauth is True: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7dc045d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,285 @@ +"""Tests for CLI options using Click's testing utilities.""" + +import os + +import pytest +from click.testing import CliRunner + +from nextcloud_mcp_server.app import run + + +@pytest.fixture +def runner(): + """Create a Click CLI runner.""" + return CliRunner() + + +@pytest.fixture +def clean_env(monkeypatch): + """Clean environment variables before each test.""" + env_vars = [ + "NEXTCLOUD_HOST", + "NEXTCLOUD_USERNAME", + "NEXTCLOUD_PASSWORD", + "NEXTCLOUD_OIDC_CLIENT_ID", + "NEXTCLOUD_OIDC_CLIENT_SECRET", + "NEXTCLOUD_OIDC_CLIENT_STORAGE", + "NEXTCLOUD_OIDC_SCOPES", + "NEXTCLOUD_OIDC_TOKEN_TYPE", + "NEXTCLOUD_MCP_SERVER_URL", + "NEXTCLOUD_PUBLIC_ISSUER_URL", + ] + for var in env_vars: + monkeypatch.delenv(var, raising=False) + + +def test_help_message_displays_all_options(runner): + """Test that help message includes all new CLI options.""" + result = runner.invoke(run, ["--help"]) + assert result.exit_code == 0 + + # Check for new options + assert "--nextcloud-host" in result.output + assert "--nextcloud-username" in result.output + assert "--nextcloud-password" in result.output + assert "--oauth-scopes" in result.output + assert "--oauth-token-type" in result.output + assert "--public-issuer-url" in result.output + + # Check for existing options + assert "--oauth-client-id" in result.output + assert "--oauth-client-secret" in result.output + assert "--mcp-server-url" in result.output + + +def test_token_type_accepts_valid_values(runner, clean_env): + """Test that --oauth-token-type accepts bearer and jwt (case insensitive).""" + # Test lowercase bearer + result = runner.invoke(run, ["--oauth-token-type", "bearer", "--help"]) + assert result.exit_code == 0 + + # Test lowercase jwt + result = runner.invoke(run, ["--oauth-token-type", "jwt", "--help"]) + assert result.exit_code == 0 + + # Test uppercase (should work with case_sensitive=False) + result = runner.invoke(run, ["--oauth-token-type", "Bearer", "--help"]) + assert result.exit_code == 0 + + result = runner.invoke(run, ["--oauth-token-type", "JWT", "--help"]) + assert result.exit_code == 0 + + +def test_token_type_rejects_invalid_values(runner, clean_env): + """Test that --oauth-token-type rejects invalid values.""" + result = runner.invoke(run, ["--oauth-token-type", "invalid"]) + assert result.exit_code != 0 + assert "Invalid value" in result.output + + +def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch): + """Test that CLI options set environment variables correctly.""" + # We need to mock the actual server startup to avoid connection errors + # Store the env vars that get set + captured_env = {} + + def mock_get_app(*args, **kwargs): + # Capture environment variables after they're set by CLI + captured_env.update( + { + "NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"), + "NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"), + "NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"), + "NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"), + "NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get( + "NEXTCLOUD_OIDC_TOKEN_TYPE" + ), + "NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get( + "NEXTCLOUD_PUBLIC_ISSUER_URL" + ), + "NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"), + } + ) + # Raise an exception to stop execution before uvicorn.run + raise SystemExit(0) + + # Patch get_app to capture env vars + monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app) + + _ = runner.invoke( + run, + [ + "--nextcloud-host", + "https://test.example.com", + "--nextcloud-username", + "testuser", + "--nextcloud-password", + "testpass", + "--oauth-scopes", + "openid nc:read", + "--oauth-token-type", + "jwt", + "--public-issuer-url", + "https://public.example.com", + "--mcp-server-url", + "http://test:8000", + ], + ) + + # Verify environment variables were set + assert captured_env["NEXTCLOUD_HOST"] == "https://test.example.com" + assert captured_env["NEXTCLOUD_USERNAME"] == "testuser" + assert captured_env["NEXTCLOUD_PASSWORD"] == "testpass" + assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:read" + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt" + assert captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public.example.com" + assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://test:8000" + + +def test_cli_options_override_environment_variables(runner, monkeypatch): + """Test that CLI options override environment variables.""" + # Set environment variables + monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com") + monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser") + monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid") + monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "bearer") + + captured_env = {} + + def mock_get_app(*args, **kwargs): + captured_env.update( + { + "NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"), + "NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"), + "NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"), + "NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get( + "NEXTCLOUD_OIDC_TOKEN_TYPE" + ), + } + ) + raise SystemExit(0) + + monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app) + + # Provide CLI options that should override env vars + _ = runner.invoke( + run, + [ + "--nextcloud-host", + "https://from-cli.example.com", + "--nextcloud-username", + "cliuser", + "--oauth-scopes", + "openid nc:write", + "--oauth-token-type", + "jwt", + ], + ) + + # Verify CLI options overrode env vars + assert captured_env["NEXTCLOUD_HOST"] == "https://from-cli.example.com" + assert captured_env["NEXTCLOUD_USERNAME"] == "cliuser" + assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:write" + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt" + + +def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch): + """Test that environment variables are used when CLI options not provided.""" + # Set environment variables + monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com") + monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser") + monkeypatch.setenv("NEXTCLOUD_PASSWORD", "envpass") + monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid email") + monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "jwt") + monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://public-env.example.com") + + captured_env = {} + + def mock_get_app(*args, **kwargs): + captured_env.update( + { + "NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"), + "NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"), + "NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"), + "NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"), + "NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get( + "NEXTCLOUD_OIDC_TOKEN_TYPE" + ), + "NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get( + "NEXTCLOUD_PUBLIC_ISSUER_URL" + ), + } + ) + raise SystemExit(0) + + monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app) + + # Don't provide any CLI options - should use env vars + _ = runner.invoke(run, []) + + # Verify env vars were used + assert captured_env["NEXTCLOUD_HOST"] == "https://from-env.example.com" + assert captured_env["NEXTCLOUD_USERNAME"] == "envuser" + assert captured_env["NEXTCLOUD_PASSWORD"] == "envpass" + assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid email" + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt" + assert ( + captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public-env.example.com" + ) + + +def test_default_values(runner, clean_env, monkeypatch): + """Test that default values are used when neither CLI nor env vars provided.""" + captured_env = {} + + def mock_get_app(*args, **kwargs): + captured_env.update( + { + "NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"), + "NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get( + "NEXTCLOUD_OIDC_TOKEN_TYPE" + ), + "NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"), + "NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get( + "NEXTCLOUD_OIDC_CLIENT_STORAGE" + ), + } + ) + raise SystemExit(0) + + monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app) + + # Don't provide CLI options or env vars - should use defaults + _ = runner.invoke(run, []) + + # Verify default values + assert ( + captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid profile email nc:read nc:write" + ) + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer" + assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000" + assert ( + captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json" + ) + + +def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch): + """Test that token type is normalized correctly regardless of input case.""" + captured_env = {} + + def mock_get_app(*args, **kwargs): + captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] = os.environ.get( + "NEXTCLOUD_OIDC_TOKEN_TYPE" + ) + raise SystemExit(0) + + monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app) + + # Test uppercase JWT + runner.invoke(run, ["--oauth-token-type", "JWT"]) + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["JWT", "jwt"] + + # Test mixed case Bearer + captured_env.clear() + runner.invoke(run, ["--oauth-token-type", "Bearer"]) + assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["Bearer", "bearer"]