diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 9085daaa8..3cf286ce3 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 account | +| `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,43 @@ 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` 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. + +```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 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..beb80d34e 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -13,6 +13,39 @@ 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 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. + +Check the active account 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..d0d3175d1 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -0,0 +1,358 @@ +"""Public Prefect Horizon authentication commands.""" + +from __future__ import annotations + +import platform +import sys +import webbrowser +from typing import Annotated, NoReturn + +from cyclopts import Parameter +from rich.status import Status + +import fastmcp +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 ( + DeviceAuthorization, + DeviceMetadata, + HorizonClient, + HorizonResponseError, + HorizonUnauthorizedError, + HorizonUnavailableError, + HorizonUser, +) +from fastmcp.cli.deploy.output import ( + CommandName, + ErrorCategory, + emit_device_challenge, + emit_error, + emit_identity, + emit_logout, + start_device_approval_status, + stop_device_approval_status, +) +from fastmcp.cli.deploy.state import StateFileError + +JsonOption = Annotated[ + bool, + Parameter( + name="--json", + help="Write one final JSON result to stdout", + negative=(), + ), +] +HostOption = Annotated[ + str | None, + Parameter( + name="--host", + help="Use and save a different Horizon host URL", + ), +] + + +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, + 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_user( + api_origin: str, + credential: ResolvedCredential, +) -> HorizonUser: + async with HorizonClient(api_origin, api_key=credential.api_key) as client: + return await client.get_current_user() + + +async def login( + *, + host: HostOption = None, + json_output: JsonOption = False, +) -> None: + """Sign in to Prefect Horizon.""" + credentials = CredentialStore() + + try: + 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(): + 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, + metadata=_device_metadata(), + 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, + authorize=device_authorization, + expected_api_origin=configuration.api_origin, + ) + + try: + user = await _get_user( + 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, + expected_api_origin=configuration.api_origin, + ) + try: + user = await _get_user( + 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, + json_output=json_output, + ) + + +async def whoami( + *, + json_output: JsonOption = False, +) -> None: + """Show the current Prefect Horizon user.""" + credentials = CredentialStore() + credential: ResolvedCredential | None = None + + try: + configuration = ConfigurationStore().load() + credential = await resolve_credential(credentials) + user = await _get_user( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError as error: + if credential is not None and credential.source == "stored": + 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, + HorizonResponseError, + HorizonUnavailableError, + StateFileError, + ) as error: + _fail_for_expected_error("whoami", error, json_output=json_output) + + emit_identity( + "whoami", + user, + 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..4110e69c7 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -0,0 +1,263 @@ +"""Stable terminal and JSON output for Horizon CLI commands.""" + +from __future__ import annotations + +import json +import sys +from typing import Literal + +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, HorizonUser + +CommandName = Literal["login", "logout", "whoami"] +ErrorCategory = Literal[ + "authentication_invalid", + "authentication_required", + "authorization_denied", + "authorization_expired", + "authorization_failed", + "horizon_error", + "horizon_unavailable", + "invalid_host", + "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 _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, + *, + 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() + console.print(_banner("Deploy FastMCP on Horizon", style="magenta")) + 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, + *, + json_output: bool, +) -> None: + """Show the authenticated user.""" + if json_output: + _write_json( + { + "ok": True, + "command": command, + "user": user.model_dump(mode="json"), + } + ) + return + + console.print() + if command == "login": + panel = _account_panel( + user, + title="Logged into Horizon", + message="You are signed in to FastMCP.", + ) + else: + panel = _account_panel( + user, + title="Horizon Account", + message="● Signed in", + ) + console.print(panel) + console.print() + + +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: + title = "Logged out of Horizon" + message = "The Horizon credential was revoked and removed from this device." + style = "green" + else: + title = "Horizon 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( + 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 + + 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 new file mode 100644 index 000000000..4cf7499c9 --- /dev/null +++ b/tests/cli/deploy/test_command.py @@ -0,0 +1,358 @@ +import json +from collections.abc import Callable +from unittest.mock import Mock +from urllib.parse import parse_qs + +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 +from fastmcp.cli.deploy.state import StateFileError + + +class HorizonAuthAPI: + def __init__( + self, + *, + token_error: str | None = None, + revoke_status: int = 204, + 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.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/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() + 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) + + 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", + }, + } + 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() + 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"} + 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], + 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 "Logged into Horizon" in output + assert "Ada" in output + assert "ada@example.com" in output + assert "Organization" not 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_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, +) -> 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..f760c2f5c --- /dev/null +++ b/tests/cli/deploy/test_output.py @@ -0,0 +1,140 @@ +import json + +import pytest + +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, 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 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_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 "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 + 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(), 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", + }, + } + + +def test_tty_identity_uses_an_account_panel( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_identity("whoami", user(), json_output=False) + + output = capsys.readouterr().out + assert "│" in output + assert "Horizon 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( + 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_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: + 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..046b6eb9b 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -35,6 +35,20 @@ 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} + + 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."""