From 35ecc14234e9a20ffab77d59825988d4134c99b4 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 21:30:31 -0700 Subject: [PATCH 1/6] feat: add Horizon account commands --- docs/cli/overview.mdx | 31 +++ docs/deployment/prefect-horizon.mdx | 29 ++ fastmcp_slim/fastmcp/cli/cli.py | 6 + fastmcp_slim/fastmcp/cli/deploy/command.py | 307 +++++++++++++++++++++ fastmcp_slim/fastmcp/cli/deploy/output.py | 143 ++++++++++ tests/cli/deploy/test_command.py | 302 ++++++++++++++++++++ tests/cli/deploy/test_output.py | 127 +++++++++ tests/cli/test_cli.py | 7 + 8 files changed, 952 insertions(+) create mode 100644 fastmcp_slim/fastmcp/cli/deploy/command.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/output.py create mode 100644 tests/cli/deploy/test_command.py create mode 100644 tests/cli/deploy/test_output.py diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 9085daaa8..5460a210b 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -28,6 +28,9 @@ fastmcp --help | [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | | [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | +| `login` | Sign in to Prefect Horizon with a browser device flow | +| `whoami` | Show the current Horizon user and organization memberships | +| `logout` | Revoke the current Horizon key and remove the local credential | | `version` | Print version info (`--copy` to copy to clipboard) | ## Server Targets @@ -81,6 +84,34 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what ## Authentication +### Prefect Horizon Account + +Use the top-level account commands to manage the credential for Prefect Horizon. + +```bash +fastmcp login +fastmcp whoami +fastmcp logout +``` + +`fastmcp login` always shows a verification URL and code. +It opens a browser when the terminal supports it. +If the browser does not open, use the shown URL and code on another device. + +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. +`fastmcp logout` attempts to revoke the active key and always removes the local credential. + +Set `HORIZON_API_KEY` to use an environment credential instead. +The CLI gives that value first precedence and never stores it. + +Use `--json` for stable command results. +During JSON login, the verification challenge goes to stderr and the final result goes to stdout. +JSON mode does not open a browser or ask a question. + +### MCP Server Authentication + When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op. To skip authentication entirely — useful for local development servers — pass `--auth none`: diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 68f157c52..6016eecf4 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -13,6 +13,35 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users. +## FastMCP CLI Account + +Sign in to Horizon from the FastMCP CLI with the device authorization flow. + +```bash +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. + +Check the active account and its current organization memberships after login. + +```bash +fastmcp whoami +``` + +Remove the local credential and revoke the active personal API key when possible. + +```bash +fastmcp logout +``` + +Login does not select or store a deployment organization. + +For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key. +The CLI never writes the environment value to its credential file. + ## The Platform Horizon is organized into four integrated pillars: diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py index 5513e3119..22c0c4596 100644 --- a/fastmcp_slim/fastmcp/cli/cli.py +++ b/fastmcp_slim/fastmcp/cli/cli.py @@ -21,6 +21,7 @@ import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command +from fastmcp.cli.deploy.command import login, logout, whoami from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config @@ -1134,6 +1135,11 @@ app.command(generate_cli_command, name="generate-cli") # Add auth subcommand group (includes CIMD commands) app.command(auth_app) +# Add Prefect Horizon account commands +app.command(login) +app.command(logout) +app.command(whoami) + if __name__ == "__main__": app() diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py new file mode 100644 index 000000000..989cbb474 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -0,0 +1,307 @@ +"""Public Prefect Horizon authentication commands.""" + +from __future__ import annotations + +import sys +import webbrowser +from typing import Annotated, NoReturn + +from cyclopts import Parameter + +from fastmcp.cli.deploy.authentication import ( + DeviceAuthorizationDeniedError, + DeviceAuthorizationError, + DeviceAuthorizationExpiredError, + authorize_device, +) +from fastmcp.cli.deploy.configuration import ConfigurationStore +from fastmcp.cli.deploy.credentials import ( + AuthenticationRequiredError, + CredentialStore, + ResolvedCredential, + resolve_credential, + revoke_and_clear_credential, +) +from fastmcp.cli.deploy.horizon_client import ( + HorizonClient, + HorizonOrganization, + HorizonResponseError, + HorizonUnauthorizedError, + HorizonUnavailableError, + HorizonUser, +) +from fastmcp.cli.deploy.output import ( + CommandName, + ErrorCategory, + emit_device_challenge, + emit_error, + emit_identity, + emit_logout, +) +from fastmcp.cli.deploy.state import StateFileError + +JsonOption = Annotated[ + bool, + Parameter( + name="--json", + help="Write one final JSON result to stdout", + negative=(), + ), +] + + +def _can_open_browser() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _fail( + command: CommandName, + category: ErrorCategory, + message: str, + *, + json_output: bool, + details: dict[str, object] | None = None, +) -> NoReturn: + emit_error( + command, + category, + message, + json_output=json_output, + details=details, + ) + raise SystemExit(1) + + +def _fail_for_expected_error( + command: CommandName, + error: Exception, + *, + json_output: bool, +) -> NoReturn: + if isinstance(error, AuthenticationRequiredError): + _fail( + command, + "authentication_required", + "Run `fastmcp login` to sign in to Prefect Horizon.", + json_output=json_output, + ) + if isinstance(error, HorizonUnauthorizedError): + _fail( + command, + "authentication_invalid", + "The Horizon credential is not valid. Run `fastmcp login` again.", + json_output=json_output, + ) + if isinstance(error, DeviceAuthorizationDeniedError): + _fail( + command, + "authorization_denied", + "The device authorization request was denied.", + json_output=json_output, + ) + if isinstance(error, DeviceAuthorizationExpiredError): + _fail( + command, + "authorization_expired", + "The device authorization request expired. Run the command again.", + json_output=json_output, + ) + if isinstance(error, DeviceAuthorizationError): + _fail( + command, + "authorization_failed", + "The device authorization request failed. Run the command again.", + json_output=json_output, + ) + if isinstance(error, HorizonUnavailableError): + _fail( + command, + "horizon_unavailable", + "The Horizon API is unavailable. Try again later.", + json_output=json_output, + ) + if isinstance(error, HorizonResponseError): + _fail( + command, + "horizon_error", + "Horizon returned an unexpected response. Try again later.", + json_output=json_output, + ) + if isinstance(error, StateFileError): + _fail( + command, + "state_error", + "The local Horizon state is invalid.", + json_output=json_output, + ) + raise error + + +async def _get_identity( + api_origin: str, + credential: ResolvedCredential, +) -> tuple[HorizonUser, tuple[HorizonOrganization, ...]]: + async with HorizonClient(api_origin, api_key=credential.api_key) as client: + user = await client.get_current_user() + organizations = await client.list_organizations() + return user, organizations + + +async def login( + *, + json_output: JsonOption = False, +) -> None: + """Sign in to Prefect Horizon.""" + credentials = CredentialStore() + + try: + configuration = ConfigurationStore().load() + + async def device_authorization(): + async with HorizonClient(configuration.api_origin) as client: + return await authorize_device( + client, + on_challenge=lambda challenge: emit_device_challenge( + challenge, + json_output=json_output, + ), + open_browser=not json_output and _can_open_browser(), + browser_opener=webbrowser.open, + ) + + credential = await resolve_credential( + credentials, + authorize=device_authorization, + ) + + try: + user, organizations = await _get_identity( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError: + if credential.source == "interactive": + credentials.clear() + raise + if credential.source == "environment": + raise + + credentials.clear() + credential = await resolve_credential( + credentials, + authorize=device_authorization, + ) + try: + user, organizations = await _get_identity( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError: + credentials.clear() + raise + except ( + AuthenticationRequiredError, + DeviceAuthorizationError, + HorizonResponseError, + HorizonUnauthorizedError, + HorizonUnavailableError, + StateFileError, + ) as error: + _fail_for_expected_error("login", error, json_output=json_output) + + emit_identity( + "login", + user, + organizations, + json_output=json_output, + ) + + +async def whoami( + *, + json_output: JsonOption = False, +) -> None: + """Show the current Prefect Horizon user and organization memberships.""" + credentials = CredentialStore() + credential: ResolvedCredential | None = None + + try: + configuration = ConfigurationStore().load() + credential = await resolve_credential(credentials) + user, organizations = await _get_identity( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError as error: + if credential is not None and credential.source == "stored": + credentials.clear() + _fail_for_expected_error("whoami", error, json_output=json_output) + except ( + AuthenticationRequiredError, + HorizonResponseError, + HorizonUnavailableError, + StateFileError, + ) as error: + _fail_for_expected_error("whoami", error, json_output=json_output) + + emit_identity( + "whoami", + user, + organizations, + json_output=json_output, + ) + + +async def logout( + *, + json_output: JsonOption = False, +) -> None: + """Revoke the current Horizon key and remove the local credential.""" + credentials = CredentialStore() + + try: + configuration = ConfigurationStore().load() + credential = await resolve_credential(credentials) + except AuthenticationRequiredError: + emit_logout(remote_revoked=False, json_output=json_output) + return + except StateFileError: + try: + credentials.clear() + except StateFileError as error: + _fail_for_expected_error("logout", error, json_output=json_output) + _fail( + "logout", + "remote_revocation_failed", + "The local credential was removed, but the remote key can remain active.", + json_output=json_output, + details={ + "localCredentialRemoved": True, + "remoteCredentialMayRemain": True, + }, + ) + + try: + async with HorizonClient( + configuration.api_origin, + api_key=credential.api_key, + ) as client: + await revoke_and_clear_credential(client, credentials) + except HorizonUnauthorizedError: + emit_logout(remote_revoked=False, json_output=json_output) + return + except (HorizonResponseError, HorizonUnavailableError): + _fail( + "logout", + "remote_revocation_failed", + "The local credential was removed, but the remote key can remain active.", + json_output=json_output, + details={ + "localCredentialRemoved": True, + "remoteCredentialMayRemain": True, + }, + ) + except StateFileError as error: + _fail_for_expected_error("logout", error, json_output=json_output) + + emit_logout(remote_revoked=True, json_output=json_output) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py new file mode 100644 index 000000000..b3b4f3114 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -0,0 +1,143 @@ +"""Stable terminal and JSON output for Horizon CLI commands.""" + +from __future__ import annotations + +import json +import sys +from typing import Literal + +from rich.console import Console + +from fastmcp.cli.deploy.horizon_client import ( + DeviceAuthorization, + HorizonOrganization, + HorizonUser, +) + +CommandName = Literal["login", "logout", "whoami"] +ErrorCategory = Literal[ + "authentication_invalid", + "authentication_required", + "authorization_denied", + "authorization_expired", + "authorization_failed", + "horizon_error", + "horizon_unavailable", + "remote_revocation_failed", + "state_error", +] + +console = Console() +error_console = Console(stderr=True) + + +def _write_json(payload: object, *, stderr: bool = False) -> None: + stream = sys.stderr if stderr else sys.stdout + print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True) + + +def emit_device_challenge( + authorization: DeviceAuthorization, + *, + json_output: bool, +) -> None: + """Show a device challenge before polling starts.""" + if json_output: + _write_json( + { + "event": "device_authorization", + "verificationUrl": authorization.verification_uri, + "verificationUrlComplete": authorization.verification_uri_complete, + "userCode": authorization.user_code, + }, + stderr=True, + ) + return + + console.print("Open this URL to sign in to Prefect Horizon:") + console.print(authorization.verification_uri) + console.print(f"Enter code: {authorization.user_code}") + console.print("Waiting for approval...") + + +def emit_identity( + command: Literal["login", "whoami"], + user: HorizonUser, + organizations: tuple[HorizonOrganization, ...], + *, + json_output: bool, +) -> None: + """Show the authenticated user and current organization memberships.""" + if json_output: + _write_json( + { + "ok": True, + "command": command, + "user": user.model_dump(mode="json"), + "organizations": [ + organization.model_dump(mode="json") + for organization in organizations + ], + } + ) + return + + prefix = "Signed in" if command == "login" else "Authenticated" + display_name = f"{user.name} <{user.email}>" if user.name else user.email + console.print(f"{prefix} as {display_name}.", markup=False) + if not organizations: + console.print("Organization memberships: none") + return + + console.print("Organization memberships:") + for organization in organizations: + console.print(f"- {organization.name} ({organization.slug})", markup=False) + + +def emit_logout( + *, + remote_revoked: bool, + json_output: bool, +) -> None: + """Show a successful local logout result.""" + if json_output: + _write_json( + { + "ok": True, + "command": "logout", + "localCredentialRemoved": True, + "remoteRevoked": remote_revoked, + } + ) + return + + if remote_revoked: + console.print("Signed out of Prefect Horizon.") + else: + console.print("No active Horizon credential remains on this device.") + + +def emit_error( + command: CommandName, + category: ErrorCategory, + message: str, + *, + json_output: bool, + details: dict[str, object] | None = None, +) -> None: + """Show a stable expected command failure.""" + if json_output: + payload: dict[str, object] = { + "ok": False, + "command": command, + "error": { + "category": category, + "message": message, + }, + } + if details: + payload.update(details) + _write_json(payload) + return + + error_console.print(f"Error: {message}", markup=False) diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py new file mode 100644 index 000000000..281a8977e --- /dev/null +++ b/tests/cli/deploy/test_command.py @@ -0,0 +1,302 @@ +import json +from collections.abc import Callable +from unittest.mock import Mock + +import httpx2 +import pytest +from pydantic import SecretStr + +import fastmcp +import fastmcp.cli.deploy.authentication as authentication_module +import fastmcp.cli.deploy.command as command_module +from fastmcp.cli.deploy.command import login, logout, whoami +from fastmcp.cli.deploy.credentials import CredentialStore +from fastmcp.cli.deploy.horizon_client import HorizonClient + + +class HorizonAuthAPI: + def __init__( + self, + *, + token_error: str | None = None, + revoke_status: int = 204, + organizations: list[dict[str, str]] | None = None, + invalid_api_key: str | None = None, + ) -> None: + self.token_error = token_error + self.revoke_status = revoke_status + self.invalid_api_key = invalid_api_key + self.organizations = organizations or [] + self.requests: list[httpx2.Request] = [] + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + self.requests.append(request) + path = request.url.path + if path == "/api/v0/oauth/device/authorization": + return httpx2.Response( + 200, + json={ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://horizon.prefect.io/oauth/device", + "verification_uri_complete": ( + "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" + ), + "expires_in": 600, + "interval": 1, + }, + ) + if path == "/api/v0/oauth/device/token": + if self.token_error is not None: + return httpx2.Response(400, json={"error": self.token_error}) + return httpx2.Response( + 200, + json={"access_token": "fmcp_device_key", "token_type": "Bearer"}, + ) + if path == "/api/v0/me": + if request.headers.get("Authorization") == ( + f"Bearer {self.invalid_api_key}" + ): + return httpx2.Response(401) + return httpx2.Response( + 200, + json={ + "user": { + "id": "user-1", + "email": "ada@example.com", + "name": "Ada", + } + }, + ) + if path == "/api/v0/me/organizations": + return httpx2.Response( + 200, + json={ + "items": self.organizations, + "meta": {"nextCursor": None, "limit": 100}, + }, + ) + if path == "/api/v0/me/api-key": + return httpx2.Response(self.revoke_status) + raise AssertionError(f"Unexpected request: {request.method} {path}") + + +@pytest.fixture +def use_horizon_api( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[HorizonAuthAPI], None]: + def use(api: HorizonAuthAPI) -> None: + transport = httpx2.MockTransport(api) + + def client( + api_origin: str, + *, + api_key: SecretStr | str | None = None, + ) -> HorizonClient: + return HorizonClient( + api_origin, + api_key=api_key, + transport=transport, + ) + + monkeypatch.setattr(command_module, "HorizonClient", client) + + return use + + +@pytest.fixture(autouse=True) +def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None: + async def sleep(_: float) -> None: + return None + + monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep) + + +async def test_json_login_writes_one_result_and_challenge_to_stderr( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = HorizonAuthAPI( + organizations=[{"id": "org-1", "name": "Acme", "slug": "acme"}] + ) + use_horizon_api(api) + browser_open = Mock() + monkeypatch.setattr(command_module.webbrowser, "open", browser_open) + + await login(json_output=True) + + captured = capsys.readouterr() + stdout_lines = captured.out.strip().splitlines() + assert len(stdout_lines) == 1 + assert json.loads(stdout_lines[0]) == { + "ok": True, + "command": "login", + "user": { + "id": "user-1", + "email": "ada@example.com", + "name": "Ada", + }, + "organizations": [{"id": "org-1", "name": "Acme", "slug": "acme"}], + } + assert json.loads(captured.err) == { + "event": "device_authorization", + "verificationUrl": "https://horizon.prefect.io/oauth/device", + "verificationUrlComplete": ( + "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" + ), + "userCode": "ABCD-EFGH", + } + browser_open.assert_not_called() + + state = json.loads(CredentialStore().path.read_text()) + assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"} + assert not (fastmcp.settings.home / "cli" / "config.json").exists() + + +async def test_tty_login_survives_browser_open_failure( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + use_horizon_api(HorizonAuthAPI()) + browser_open = Mock(side_effect=OSError("No browser")) + monkeypatch.setattr(command_module, "_can_open_browser", lambda: True) + monkeypatch.setattr(command_module.webbrowser, "open", browser_open) + + await login() + + output = capsys.readouterr().out + assert "https://horizon.prefect.io/oauth/device" in output + assert "ABCD-EFGH" in output + assert "Signed in as Ada ." in output + assert "Organization memberships: none" in output + browser_open.assert_called_once() + + +async def test_whoami_uses_the_stored_key_after_a_restart( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + api = HorizonAuthAPI() + use_horizon_api(api) + await login(json_output=True) + capsys.readouterr() + + await whoami(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["command"] == "whoami" + assert result["user"]["email"] == "ada@example.com" + assert [request.url.path for request in api.requests].count("/api/v0/me") == 2 + + +async def test_login_replaces_an_invalid_stored_key( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key")) + CredentialStore().save("fmcp_stale_key") + + await login(json_output=True) + + captured = capsys.readouterr() + assert json.loads(captured.out)["ok"] is True + assert json.loads(captured.err)["event"] == "device_authorization" + stored_key = CredentialStore().load() + assert stored_key is not None + assert stored_key.get_secret_value() == "fmcp_device_key" + + +async def test_login_never_persists_an_environment_key( + use_horizon_api: Callable[[HorizonAuthAPI], None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = HorizonAuthAPI() + use_horizon_api(api) + monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key") + + await login(json_output=True) + + assert CredentialStore().path.exists() is False + assert not any( + request.url.path.startswith("/api/v0/oauth/device") for request in api.requests + ) + + +async def test_json_whoami_does_not_start_device_authorization( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + browser_open = Mock() + monkeypatch.setattr(command_module.webbrowser, "open", browser_open) + + with pytest.raises(SystemExit, match="1"): + await whoami(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == "authentication_required" + browser_open.assert_not_called() + + +@pytest.mark.parametrize( + ("token_error", "category"), + [ + ("access_denied", "authorization_denied"), + ("expired_token", "authorization_expired"), + ], +) +async def test_json_login_reports_stable_device_failures( + token_error: str, + category: str, + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + use_horizon_api(HorizonAuthAPI(token_error=token_error)) + + with pytest.raises(SystemExit, match="1"): + await login(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == category + assert CredentialStore().path.exists() is False + + +async def test_logout_revokes_the_remote_key_and_clears_local_state( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + api = HorizonAuthAPI() + use_horizon_api(api) + CredentialStore().save("fmcp_stored_key") + + await logout(json_output=True) + + assert json.loads(capsys.readouterr().out) == { + "ok": True, + "command": "logout", + "localCredentialRemoved": True, + "remoteRevoked": True, + } + assert CredentialStore().path.exists() is False + assert any( + request.method == "DELETE" and request.url.path == "/api/v0/me/api-key" + for request in api.requests + ) + + +async def test_logout_clears_local_state_when_remote_revocation_fails( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + use_horizon_api(HorizonAuthAPI(revoke_status=503)) + CredentialStore().save("fmcp_stored_key") + + with pytest.raises(SystemExit, match="1"): + await logout(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == "remote_revocation_failed" + assert result["localCredentialRemoved"] is True + assert result["remoteCredentialMayRemain"] is True + assert CredentialStore().path.exists() is False diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py new file mode 100644 index 000000000..e5258baa1 --- /dev/null +++ b/tests/cli/deploy/test_output.py @@ -0,0 +1,127 @@ +import json + +import pytest + +from fastmcp.cli.deploy.horizon_client import ( + DeviceAuthorization, + HorizonOrganization, + HorizonUser, +) +from fastmcp.cli.deploy.output import ( + emit_device_challenge, + emit_error, + emit_identity, + emit_logout, +) + + +def authorization() -> DeviceAuthorization: + return DeviceAuthorization( + device_code="device-secret", + user_code="ABCD-EFGH", + verification_uri="https://horizon.prefect.io/oauth/device", + verification_uri_complete=( + "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" + ), + expires_in=600, + interval=5, + ) + + +def user() -> HorizonUser: + return HorizonUser(id="user-1", email="ada@example.com", name="Ada") + + +def organizations() -> tuple[HorizonOrganization, ...]: + return ( + HorizonOrganization(id="org-1", name="Acme", slug="acme"), + HorizonOrganization(id="org-2", name="Research", slug="research"), + ) + + +def test_json_device_challenge_uses_only_stderr( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_device_challenge(authorization(), json_output=True) + + captured = capsys.readouterr() + assert captured.out == "" + assert json.loads(captured.err) == { + "event": "device_authorization", + "verificationUrl": "https://horizon.prefect.io/oauth/device", + "verificationUrlComplete": ( + "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" + ), + "userCode": "ABCD-EFGH", + } + + +def test_json_identity_has_stable_fields( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_identity("login", user(), organizations(), json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result == { + "ok": True, + "command": "login", + "user": { + "id": "user-1", + "email": "ada@example.com", + "name": "Ada", + }, + "organizations": [ + {"id": "org-1", "name": "Acme", "slug": "acme"}, + {"id": "org-2", "name": "Research", "slug": "research"}, + ], + } + + +def test_tty_identity_handles_no_organizations( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_identity("whoami", user(), (), json_output=False) + + output = capsys.readouterr().out + assert "Authenticated as Ada ." in output + assert "Organization memberships: none" in output + + +def test_json_error_has_stable_fields( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_error( + "logout", + "remote_revocation_failed", + "The remote key can remain active.", + json_output=True, + details={ + "localCredentialRemoved": True, + "remoteCredentialMayRemain": True, + }, + ) + + result = json.loads(capsys.readouterr().out) + assert result == { + "ok": False, + "command": "logout", + "error": { + "category": "remote_revocation_failed", + "message": "The remote key can remain active.", + }, + "localCredentialRemoved": True, + "remoteCredentialMayRemain": True, + } + + +def test_json_logout_has_stable_fields( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_logout(remote_revoked=True, json_output=True) + + assert json.loads(capsys.readouterr().out) == { + "ok": True, + "command": "logout", + "localCredentialRemoved": True, + "remoteRevoked": True, + } diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 7100683bc..93282159a 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -35,6 +35,13 @@ class TestMainCLI: assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 + @pytest.mark.parametrize("name", ["login", "logout", "whoami"]) + def test_horizon_account_commands_are_top_level(self, name: str): + command, bound, _ = app.parse_args([name, "--json"]) + + assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert bound.arguments == {"json_output": True} + class TestVersionCommand: """Test the version command.""" From e877785511bed0b9351abb5646903cc236ad0f82 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 22:49:47 -0700 Subject: [PATCH 2/6] 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.""" From 0c955d4828a1998274f38d57b19ef0889ac5513c Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:02:29 -0700 Subject: [PATCH 3/6] feat: refine Horizon account output --- docs/cli/overview.mdx | 4 +- docs/deployment/prefect-horizon.mdx | 2 +- fastmcp_slim/fastmcp/cli/deploy/command.py | 50 +++--- fastmcp_slim/fastmcp/cli/deploy/output.py | 177 +++++++++++++++++---- tests/cli/deploy/test_command.py | 21 +-- tests/cli/deploy/test_output.py | 45 +++--- 6 files changed, 208 insertions(+), 91 deletions(-) diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 9a41239ff..39b5a4abf 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -29,7 +29,7 @@ fastmcp --help | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | | [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | | `login` | Sign in to Prefect Horizon with a browser device flow | -| `whoami` | Show the current Horizon user and organization memberships | +| `whoami` | Show the current Horizon account | | `logout` | Revoke the current Horizon key and remove the local credential | | `version` | Print version info (`--copy` to copy to clipboard) | @@ -107,7 +107,7 @@ 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. +`fastmcp whoami` gets the current user from Horizon. `fastmcp logout` attempts to revoke the active key and always removes the local credential. Set `HORIZON_API_KEY` to use an environment credential instead. diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 565424522..cab9795d1 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -27,7 +27,7 @@ New users can register and create their first Horizon organization in the browse 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. +Check the active account after login. ```bash fastmcp whoami diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py index cf8effef1..630b12e88 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -7,6 +7,7 @@ import webbrowser from typing import Annotated, NoReturn from cyclopts import Parameter +from rich.status import Status from fastmcp.cli.deploy.authentication import ( DeviceAuthorizationDeniedError, @@ -23,8 +24,8 @@ from fastmcp.cli.deploy.credentials import ( revoke_and_clear_credential, ) from fastmcp.cli.deploy.horizon_client import ( + DeviceAuthorization, HorizonClient, - HorizonOrganization, HorizonResponseError, HorizonUnauthorizedError, HorizonUnavailableError, @@ -37,6 +38,8 @@ from fastmcp.cli.deploy.output import ( emit_error, emit_identity, emit_logout, + start_device_approval_status, + stop_device_approval_status, ) from fastmcp.cli.deploy.state import StateFileError @@ -144,14 +147,12 @@ def _fail_for_expected_error( raise error -async def _get_identity( +async def _get_user( api_origin: str, credential: ResolvedCredential, -) -> tuple[HorizonUser, tuple[HorizonOrganization, ...]]: +) -> HorizonUser: async with HorizonClient(api_origin, api_key=credential.api_key) as client: - user = await client.get_current_user() - organizations = await client.list_organizations() - return user, organizations + return await client.get_current_user() async def login( @@ -181,16 +182,23 @@ async def login( ) async def device_authorization(): - async with HorizonClient(configuration.api_origin) as client: - return await authorize_device( - client, - on_challenge=lambda challenge: emit_device_challenge( - challenge, - json_output=json_output, - ), - open_browser=not json_output and _can_open_browser(), - browser_opener=webbrowser.open, - ) + approval_status: Status | None = None + + def show_challenge(challenge: DeviceAuthorization) -> None: + nonlocal approval_status + emit_device_challenge(challenge, json_output=json_output) + approval_status = start_device_approval_status(json_output=json_output) + + try: + async with HorizonClient(configuration.api_origin) as client: + return await authorize_device( + client, + on_challenge=show_challenge, + open_browser=not json_output and _can_open_browser(), + browser_opener=webbrowser.open, + ) + finally: + stop_device_approval_status(approval_status) credential = await resolve_credential( credentials, @@ -198,7 +206,7 @@ async def login( ) try: - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -215,7 +223,7 @@ async def login( authorize=device_authorization, ) try: - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -235,7 +243,6 @@ async def login( emit_identity( "login", user, - organizations, json_output=json_output, ) @@ -244,14 +251,14 @@ async def whoami( *, json_output: JsonOption = False, ) -> None: - """Show the current Prefect Horizon user and organization memberships.""" + """Show the current Prefect Horizon user.""" credentials = CredentialStore() credential: ResolvedCredential | None = None try: configuration = ConfigurationStore().load() credential = await resolve_credential(credentials) - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -270,7 +277,6 @@ async def whoami( emit_identity( "whoami", user, - organizations, json_output=json_output, ) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py index 624b6d469..925184ea4 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/output.py +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -6,13 +6,16 @@ import json import sys from typing import Literal -from rich.console import Console +from rich import box +from rich.align import Align +from rich.console import Console, Group +from rich.padding import Padding +from rich.panel import Panel +from rich.status import Status +from rich.table import Table +from rich.text import Text -from fastmcp.cli.deploy.horizon_client import ( - DeviceAuthorization, - HorizonOrganization, - HorizonUser, -) +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser CommandName = Literal["login", "logout", "whoami"] ErrorCategory = Literal[ @@ -37,6 +40,47 @@ def _write_json(payload: object, *, stderr: bool = False) -> None: print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True) +def _banner(title: str, *, style: str) -> Panel: + return Panel( + Align.center(Text(title, style=f"bold {style}")), + box=box.ROUNDED, + border_style=style, + padding=(0, 1), + width=52, + ) + + +def _account_panel( + user: HorizonUser, + *, + title: str, + message: str, +) -> Panel: + name = Text(user.name or user.email, style="bold") + details: list[Text] = [name] + if user.name: + details.append(Text(user.email, style="cyan")) + details.extend([Text(), Text(message, style="green")]) + return Panel( + Group(*details), + title=Text(title, style="bold green"), + title_align="left", + box=box.ROUNDED, + border_style="green", + padding=(1, 2), + width=52, + ) + + +def _format_duration(seconds: int) -> str: + if seconds % 60 == 0: + minutes = seconds // 60 + unit = "minute" if minutes == 1 else "minutes" + return f"{minutes} {unit}" + unit = "second" if seconds == 1 else "seconds" + return f"{seconds} {unit}" + + def emit_device_challenge( authorization: DeviceAuthorization, *, @@ -55,44 +99,84 @@ def emit_device_challenge( ) return - console.print("Open this URL to sign in to Prefect Horizon:") - console.print(authorization.verification_uri) - console.print(f"Enter code: {authorization.user_code}") - console.print("Waiting for approval...") + console.print() + console.print(_banner("FastMCP CLI Sign In", style="cyan")) + console.print() + console.print(Text("✓ Device authorization started", style="bold green")) + console.print() + console.print(" Open this URL in your browser:") + console.print() + console.print( + Padding( + Text(authorization.verification_uri_complete, style="cyan underline"), + (0, 2), + ) + ) + console.print() + console.print(" Confirm this code:") + console.print() + code = Table.grid() + code.add_column(justify="center", width=52) + code.add_row(Text(authorization.user_code, style="bold")) + console.print(code) + console.print() + expires_in = _format_duration(authorization.expires_in) + console.print(Text(f"The request expires in {expires_in}.", style="dim")) + console.print(Text("Press Ctrl-C to cancel.", style="dim")) + console.print() + + +def start_device_approval_status(*, json_output: bool) -> Status | None: + """Start the terminal spinner while the browser approval is pending.""" + if json_output: + return None + status = console.status( + "[cyan]Waiting for approval in your browser[/cyan]", + spinner="dots", + spinner_style="cyan", + ) + status.start() + return status + + +def stop_device_approval_status(status: Status | None) -> None: + """Stop a device approval spinner when one is active.""" + if status is not None: + status.stop() def emit_identity( command: Literal["login", "whoami"], user: HorizonUser, - organizations: tuple[HorizonOrganization, ...], *, json_output: bool, ) -> None: - """Show the authenticated user and current organization memberships.""" + """Show the authenticated user.""" if json_output: _write_json( { "ok": True, "command": command, "user": user.model_dump(mode="json"), - "organizations": [ - organization.model_dump(mode="json") - for organization in organizations - ], } ) return - prefix = "Signed in" if command == "login" else "Authenticated" - display_name = f"{user.name} <{user.email}>" if user.name else user.email - console.print(f"{prefix} as {display_name}.", markup=False) - if not organizations: - console.print("Organization memberships: none") - return - - console.print("Organization memberships:") - for organization in organizations: - console.print(f"- {organization.name} ({organization.slug})", markup=False) + console.print() + if command == "login": + panel = _account_panel( + user, + title="✓ Authorization complete", + message="You are signed in to FastMCP.", + ) + else: + panel = _account_panel( + user, + title="FastMCP Account", + message="● Signed in", + ) + console.print(panel) + console.print() def emit_logout( @@ -113,9 +197,27 @@ def emit_logout( return if remote_revoked: - console.print("Signed out of Prefect Horizon.") + title = "✓ Signed out of FastMCP" + message = "The Horizon credential was revoked and removed from this device." + style = "green" else: - console.print("No active Horizon credential remains on this device.") + title = "FastMCP Account" + message = "No active Horizon credential remains on this device." + style = "cyan" + + console.print() + console.print( + Panel( + Text(message), + title=Text(title, style=f"bold {style}"), + title_align="left", + box=box.ROUNDED, + border_style=style, + padding=(1, 2), + width=60, + ) + ) + console.print() def emit_error( @@ -141,4 +243,21 @@ def emit_error( _write_json(payload) return - error_console.print(f"Error: {message}", markup=False) + titles = { + "login": "✗ Sign in failed", + "logout": "✗ Sign out failed", + "whoami": "✗ Account lookup failed", + } + error_console.print() + error_console.print( + Panel( + Text(message), + title=Text(titles[command], style="bold red"), + title_align="left", + box=box.ROUNDED, + border_style="red", + padding=(1, 2), + width=60, + ) + ) + error_console.print() diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index 6bcf32b70..b7473cc8d 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -20,13 +20,11 @@ class HorizonAuthAPI: *, token_error: str | None = None, revoke_status: int = 204, - organizations: list[dict[str, str]] | None = None, invalid_api_key: str | None = None, ) -> None: self.token_error = token_error self.revoke_status = revoke_status self.invalid_api_key = invalid_api_key - self.organizations = organizations or [] self.requests: list[httpx2.Request] = [] def __call__(self, request: httpx2.Request) -> httpx2.Response: @@ -68,14 +66,6 @@ class HorizonAuthAPI: } }, ) - if path == "/api/v0/me/organizations": - return httpx2.Response( - 200, - json={ - "items": self.organizations, - "meta": {"nextCursor": None, "limit": 100}, - }, - ) if path == "/api/v0/me/api-key": return httpx2.Response(self.revoke_status) raise AssertionError(f"Unexpected request: {request.method} {path}") @@ -117,9 +107,7 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: - api = HorizonAuthAPI( - organizations=[{"id": "org-1", "name": "Acme", "slug": "acme"}] - ) + api = HorizonAuthAPI() use_horizon_api(api) browser_open = Mock() monkeypatch.setattr(command_module.webbrowser, "open", browser_open) @@ -137,7 +125,6 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( "email": "ada@example.com", "name": "Ada", }, - "organizations": [{"id": "org-1", "name": "Acme", "slug": "acme"}], } assert json.loads(captured.err) == { "event": "device_authorization", @@ -198,8 +185,10 @@ async def test_tty_login_survives_browser_open_failure( output = capsys.readouterr().out assert "https://horizon.prefect.io/oauth/device" in output assert "ABCD-EFGH" in output - assert "Signed in as Ada ." in output - assert "Organization memberships: none" in output + assert "✓ Authorization complete" in output + assert "Ada" in output + assert "ada@example.com" in output + assert "Organization" not in output browser_open.assert_called_once() diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py index e5258baa1..3af6f1501 100644 --- a/tests/cli/deploy/test_output.py +++ b/tests/cli/deploy/test_output.py @@ -2,11 +2,7 @@ import json import pytest -from fastmcp.cli.deploy.horizon_client import ( - DeviceAuthorization, - HorizonOrganization, - HorizonUser, -) +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser from fastmcp.cli.deploy.output import ( emit_device_challenge, emit_error, @@ -32,13 +28,6 @@ def user() -> HorizonUser: return HorizonUser(id="user-1", email="ada@example.com", name="Ada") -def organizations() -> tuple[HorizonOrganization, ...]: - return ( - HorizonOrganization(id="org-1", name="Acme", slug="acme"), - HorizonOrganization(id="org-2", name="Research", slug="research"), - ) - - def test_json_device_challenge_uses_only_stderr( capsys: pytest.CaptureFixture[str], ) -> None: @@ -56,10 +45,24 @@ def test_json_device_challenge_uses_only_stderr( } +def test_tty_device_challenge_uses_the_sign_in_layout( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_device_challenge(authorization(), json_output=False) + + output = capsys.readouterr().out + assert "╭" in output + assert "FastMCP CLI Sign In" in output + assert "✓ Device authorization started" in output + assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output + assert "ABCD-EFGH" in output + assert "The request expires in 10 minutes." in output + + def test_json_identity_has_stable_fields( capsys: pytest.CaptureFixture[str], ) -> None: - emit_identity("login", user(), organizations(), json_output=True) + emit_identity("login", user(), json_output=True) result = json.loads(capsys.readouterr().out) assert result == { @@ -70,21 +73,21 @@ def test_json_identity_has_stable_fields( "email": "ada@example.com", "name": "Ada", }, - "organizations": [ - {"id": "org-1", "name": "Acme", "slug": "acme"}, - {"id": "org-2", "name": "Research", "slug": "research"}, - ], } -def test_tty_identity_handles_no_organizations( +def test_tty_identity_uses_an_account_panel( capsys: pytest.CaptureFixture[str], ) -> None: - emit_identity("whoami", user(), (), json_output=False) + emit_identity("whoami", user(), json_output=False) output = capsys.readouterr().out - assert "Authenticated as Ada ." in output - assert "Organization memberships: none" in output + assert "╭" in output + assert "FastMCP Account" in output + assert "Ada" in output + assert "ada@example.com" in output + assert "● Signed in" in output + assert "Organization" not in output def test_json_error_has_stable_fields( From 70c67b9c0148b7c9770913fc4881e21314a1170b Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:28:12 -0700 Subject: [PATCH 4/6] style: refine Horizon output headings --- fastmcp_slim/fastmcp/cli/deploy/output.py | 10 +++++----- tests/cli/deploy/test_command.py | 2 +- tests/cli/deploy/test_output.py | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py index 925184ea4..4110e69c7 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/output.py +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -100,7 +100,7 @@ def emit_device_challenge( return console.print() - console.print(_banner("FastMCP CLI Sign In", style="cyan")) + console.print(_banner("Deploy FastMCP on Horizon", style="magenta")) console.print() console.print(Text("✓ Device authorization started", style="bold green")) console.print() @@ -166,13 +166,13 @@ def emit_identity( if command == "login": panel = _account_panel( user, - title="✓ Authorization complete", + title="Logged into Horizon", message="You are signed in to FastMCP.", ) else: panel = _account_panel( user, - title="FastMCP Account", + title="Horizon Account", message="● Signed in", ) console.print(panel) @@ -197,11 +197,11 @@ def emit_logout( return if remote_revoked: - title = "✓ Signed out of FastMCP" + title = "Logged out of Horizon" message = "The Horizon credential was revoked and removed from this device." style = "green" else: - title = "FastMCP Account" + title = "Horizon Account" message = "No active Horizon credential remains on this device." style = "cyan" diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index b7473cc8d..f4c9e2949 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -185,7 +185,7 @@ async def test_tty_login_survives_browser_open_failure( output = capsys.readouterr().out assert "https://horizon.prefect.io/oauth/device" in output assert "ABCD-EFGH" in output - assert "✓ Authorization complete" in output + assert "Logged into Horizon" in output assert "Ada" in output assert "ada@example.com" in output assert "Organization" not in output diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py index 3af6f1501..79b45d875 100644 --- a/tests/cli/deploy/test_output.py +++ b/tests/cli/deploy/test_output.py @@ -52,7 +52,7 @@ def test_tty_device_challenge_uses_the_sign_in_layout( output = capsys.readouterr().out assert "╭" in output - assert "FastMCP CLI Sign In" in output + assert "Deploy FastMCP on Horizon" in output assert "✓ Device authorization started" in output assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output assert "ABCD-EFGH" in output @@ -83,7 +83,7 @@ def test_tty_identity_uses_an_account_panel( output = capsys.readouterr().out assert "╭" in output - assert "FastMCP Account" in output + assert "Horizon Account" in output assert "Ada" in output assert "ada@example.com" in output assert "● Signed in" in output @@ -117,6 +117,16 @@ def test_json_error_has_stable_fields( } +def test_tty_logout_uses_the_horizon_header( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_logout(remote_revoked=True, json_output=False) + + output = capsys.readouterr().out + assert "Logged out of Horizon" in output + assert "╭" in output + + def test_json_logout_has_stable_fields( capsys: pytest.CaptureFixture[str], ) -> None: From 8b930d207e0506f3ce3e0a051e6f0c0b6f3009e5 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:31:32 -0700 Subject: [PATCH 5/6] feat: describe device authorization requests --- fastmcp_slim/fastmcp/cli/deploy/command.py | 13 +++++++++++++ tests/cli/deploy/test_command.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py index 630b12e88..6ac03a824 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -2,6 +2,7 @@ from __future__ import annotations +import platform import sys import webbrowser from typing import Annotated, NoReturn @@ -9,6 +10,7 @@ from typing import Annotated, NoReturn from cyclopts import Parameter from rich.status import Status +import fastmcp from fastmcp.cli.deploy.authentication import ( DeviceAuthorizationDeniedError, DeviceAuthorizationError, @@ -25,6 +27,7 @@ from fastmcp.cli.deploy.credentials import ( ) from fastmcp.cli.deploy.horizon_client import ( DeviceAuthorization, + DeviceMetadata, HorizonClient, HorizonResponseError, HorizonUnauthorizedError, @@ -64,6 +67,15 @@ def _can_open_browser() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() +def _device_metadata() -> DeviceMetadata: + return DeviceMetadata( + device_name=platform.node() or None, + platform=platform.system().lower() or None, + architecture=platform.machine().lower() or None, + client_version=fastmcp.__version__, + ) + + def _fail( command: CommandName, category: ErrorCategory, @@ -193,6 +205,7 @@ async def login( async with HorizonClient(configuration.api_origin) as client: return await authorize_device( client, + metadata=_device_metadata(), on_challenge=show_challenge, open_browser=not json_output and _can_open_browser(), browser_opener=webbrowser.open, diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index f4c9e2949..d7f696aca 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -1,6 +1,7 @@ import json from collections.abc import Callable from unittest.mock import Mock +from urllib.parse import parse_qs import httpx2 import pytest @@ -111,6 +112,10 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( use_horizon_api(api) browser_open = Mock() monkeypatch.setattr(command_module.webbrowser, "open", browser_open) + monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop") + monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin") + monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64") + monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0") await login(json_output=True) @@ -135,6 +140,18 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( "userCode": "ABCD-EFGH", } browser_open.assert_not_called() + authorization_request = next( + request + for request in api.requests + if request.url.path == "/api/v0/oauth/device/authorization" + ) + assert parse_qs(authorization_request.content.decode()) == { + "client_id": ["fastmcp-cli"], + "device_name": ["Avery's laptop"], + "platform": ["darwin"], + "architecture": ["arm64"], + "client_version": ["4.0.0"], + } state = json.loads(CredentialStore().path.read_text()) assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"} From e532e740bea271b5aa4fc5c4d43ecd36e9840a1c Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:50:52 -0700 Subject: [PATCH 6/6] fix: preserve Horizon command contracts --- docs/cli/overview.mdx | 4 +++- docs/deployment/prefect-horizon.mdx | 4 +++- fastmcp_slim/fastmcp/cli/deploy/command.py | 11 ++++++++++- tests/cli/deploy/test_command.py | 21 +++++++++++++++++++++ tests/cli/deploy/test_output.py | 6 +++--- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 39b5a4abf..3cf286ce3 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -94,9 +94,11 @@ fastmcp whoami fastmcp logout ``` -`fastmcp login` always shows a verification URL and code. +`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available. +When login needs a new key, it shows a verification URL and code. It opens a browser when the terminal supports it. If the browser does not open, use the shown URL and code on another device. +To switch accounts, run `fastmcp logout` before you run `fastmcp login` again. Use `--host` to connect to another Horizon environment. The CLI saves the host and clears the stored key before a host change. diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index cab9795d1..beb80d34e 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -21,8 +21,10 @@ Sign in to Horizon from the FastMCP CLI with the device authorization flow. fastmcp login ``` -The command shows a verification URL and code before it opens the browser. +The command uses an environment key or a valid stored key when one is available. +When login needs a new key, it shows a verification URL and code before it opens the browser. If the browser cannot open, visit the shown URL and enter the code. +To switch accounts, run `fastmcp logout` before you run `fastmcp login` again. 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. diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py index 6ac03a824..d0d3175d1 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -216,6 +216,7 @@ async def login( credential = await resolve_credential( credentials, authorize=device_authorization, + expected_api_origin=configuration.api_origin, ) try: @@ -234,6 +235,7 @@ async def login( credential = await resolve_credential( credentials, authorize=device_authorization, + expected_api_origin=configuration.api_origin, ) try: user = await _get_user( @@ -277,7 +279,14 @@ async def whoami( ) except HorizonUnauthorizedError as error: if credential is not None and credential.source == "stored": - credentials.clear() + try: + credentials.clear() + except StateFileError as cleanup_error: + _fail_for_expected_error( + "whoami", + cleanup_error, + json_output=json_output, + ) _fail_for_expected_error("whoami", error, json_output=json_output) except ( AuthenticationRequiredError, diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index d7f696aca..4cf7499c9 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -13,6 +13,7 @@ import fastmcp.cli.deploy.command as command_module from fastmcp.cli.deploy.command import login, logout, whoami from fastmcp.cli.deploy.credentials import CredentialStore from fastmcp.cli.deploy.horizon_client import HorizonClient +from fastmcp.cli.deploy.state import StateFileError class HorizonAuthAPI: @@ -259,6 +260,26 @@ async def test_login_never_persists_an_environment_key( ) +async def test_json_whoami_reports_a_failed_rejected_key_cleanup( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key")) + CredentialStore().save("fmcp_stale_key") + + def fail_clear(store: CredentialStore) -> None: + raise StateFileError("cleanup failed") + + monkeypatch.setattr(CredentialStore, "clear", fail_clear) + + with pytest.raises(SystemExit, match="1"): + await whoami(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == "state_error" + + async def test_json_whoami_does_not_start_device_authorization( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py index 79b45d875..f760c2f5c 100644 --- a/tests/cli/deploy/test_output.py +++ b/tests/cli/deploy/test_output.py @@ -51,7 +51,7 @@ def test_tty_device_challenge_uses_the_sign_in_layout( emit_device_challenge(authorization(), json_output=False) output = capsys.readouterr().out - assert "╭" in output + assert "│" in output assert "Deploy FastMCP on Horizon" in output assert "✓ Device authorization started" in output assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output @@ -82,7 +82,7 @@ def test_tty_identity_uses_an_account_panel( emit_identity("whoami", user(), json_output=False) output = capsys.readouterr().out - assert "╭" in output + assert "│" in output assert "Horizon Account" in output assert "Ada" in output assert "ada@example.com" in output @@ -124,7 +124,7 @@ def test_tty_logout_uses_the_horizon_header( output = capsys.readouterr().out assert "Logged out of Horizon" in output - assert "╭" in output + assert "│" in output def test_json_logout_has_stable_fields(