From e877785511bed0b9351abb5646903cc236ad0f82 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 22:49:47 -0700 Subject: [PATCH] feat: add a Horizon host option to login --- docs/cli/overview.mdx | 7 ++++++ docs/deployment/prefect-horizon.mdx | 2 ++ fastmcp_slim/fastmcp/cli/deploy/command.py | 25 ++++++++++++++++++- fastmcp_slim/fastmcp/cli/deploy/output.py | 1 + tests/cli/deploy/test_command.py | 29 ++++++++++++++++++++++ tests/cli/test_cli.py | 7 ++++++ 6 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 5460a210b..9a41239ff 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -98,6 +98,13 @@ fastmcp logout It opens a browser when the terminal supports it. If the browser does not open, use the shown URL and code on another device. +Use `--host` to connect to another Horizon environment. +The CLI saves the host and clears the stored key before a host change. + +```bash +fastmcp login --host https://horizon.example.com +``` + Login stores only the personal Horizon API key. It does not select or store a deployment organization. `fastmcp whoami` gets the current user and organization memberships from Horizon. diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 6016eecf4..565424522 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -24,6 +24,8 @@ fastmcp login The command shows a verification URL and code before it opens the browser. If the browser cannot open, visit the shown URL and enter the code. New users can register and create their first Horizon organization in the browser. +Use `fastmcp login --host ` to save a different Horizon host. +A host change clears the stored credential before login. Check the active account and its current organization memberships after login. diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py index 989cbb474..cf8effef1 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -48,6 +48,13 @@ JsonOption = Annotated[ negative=(), ), ] +HostOption = Annotated[ + str | None, + Parameter( + name="--host", + help="Use and save a different Horizon host URL", + ), +] def _can_open_browser() -> bool: @@ -149,13 +156,29 @@ async def _get_identity( async def login( *, + host: HostOption = None, json_output: JsonOption = False, ) -> None: """Sign in to Prefect Horizon.""" credentials = CredentialStore() try: - configuration = ConfigurationStore().load() + configuration_store = ConfigurationStore() + if host is None: + configuration = configuration_store.load() + else: + try: + configuration = configuration_store.set_api_origin( + host, + credentials=credentials, + ) + except ValueError: + _fail( + "login", + "invalid_host", + "The Horizon host must be an HTTP origin.", + json_output=json_output, + ) async def device_authorization(): async with HorizonClient(configuration.api_origin) as client: diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py index b3b4f3114..624b6d469 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/output.py +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -23,6 +23,7 @@ ErrorCategory = Literal[ "authorization_failed", "horizon_error", "horizon_unavailable", + "invalid_host", "remote_revocation_failed", "state_error", ] diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index 281a8977e..6bcf32b70 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -154,6 +154,35 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( assert not (fastmcp.settings.home / "cli" / "config.json").exists() +async def test_login_host_is_saved_before_device_authorization( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + api = HorizonAuthAPI() + use_horizon_api(api) + + await login(host="https://dev.horizon.prefect.io/", json_output=True) + + assert json.loads(capsys.readouterr().out)["ok"] is True + configuration_path = fastmcp.settings.home / "cli" / "config.json" + assert json.loads(configuration_path.read_text()) == { + "schemaVersion": 1, + "apiOrigin": "https://dev.horizon.prefect.io", + } + assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"} + + +async def test_login_rejects_an_invalid_host( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit, match="1"): + await login(host="https://horizon.prefect.io/path", json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == "invalid_host" + assert CredentialStore().path.exists() is False + + async def test_tty_login_survives_browser_open_failure( use_horizon_api: Callable[[HorizonAuthAPI], None], capsys: pytest.CaptureFixture[str], diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 93282159a..046b6eb9b 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -42,6 +42,13 @@ class TestMainCLI: assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] assert bound.arguments == {"json_output": True} + def test_login_accepts_a_horizon_host(self): + _, bound, _ = app.parse_args( + ["login", "--host", "https://dev.horizon.prefect.io"] + ) + + assert bound.arguments == {"host": "https://dev.horizon.prefect.io"} + class TestVersionCommand: """Test the version command."""