chore: Make all env vars available to be overriden as cli options

This commit is contained in:
Chris Coutinho
2025-10-23 11:48:01 +02:00
parent b4039e2e40
commit 737780b417
2 changed files with 358 additions and 4 deletions
+73 -4
View File
@@ -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:
+285
View File
@@ -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"]