From 0d86007617876558ddf7119291e0343e4b9398ac Mon Sep 17 00:00:00 2001 From: Eddie Date: Mon, 17 Aug 2026 20:11:19 -0700 Subject: [PATCH] Add Prefect Horizon account commands (#4786) * feat: add Horizon account commands * feat: add a Horizon host option to login * feat: refine Horizon account output * style: refine Horizon output headings * feat: describe device authorization requests * fix: preserve Horizon command contracts * fix: preserve environment credentials on logout * fix: keep Horizon state reads consistent * docs: hide the Horizon host override --- docs/cli/overview.mdx | 35 ++ docs/deployment/prefect-horizon.mdx | 33 ++ fastmcp_slim/fastmcp/cli/cli.py | 6 + fastmcp_slim/fastmcp/cli/deploy/command.py | 398 +++++++++++++++ .../fastmcp/cli/deploy/credentials.py | 27 + fastmcp_slim/fastmcp/cli/deploy/output.py | 297 +++++++++++ tests/cli/deploy/test_command.py | 462 ++++++++++++++++++ tests/cli/deploy/test_credentials.py | 23 + tests/cli/deploy/test_output.py | 167 +++++++ tests/cli/test_cli.py | 14 + 10 files changed, 1462 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..675514ecc 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,38 @@ 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. + +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 stored key and always removes its local credential. + +Set `HORIZON_API_KEY` to use an environment credential instead. +The CLI gives that value first precedence and never stores it. +When this variable controls the session, logout does not revoke or remove any credential. +Remove the variable from your environment to sign out. + +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..9ff6c5356 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. + +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. +When this variable controls the session, logout does not revoke or remove any credential. +Remove the variable from the environment to sign out. + ## 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..a0a256c9e --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -0,0 +1,398 @@ +"""Public Prefect Horizon authentication commands.""" + +from __future__ import annotations + +import os +import platform +import sys +import webbrowser +from typing import Annotated, NoReturn + +from cyclopts import Parameter +from pydantic import SecretStr +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, + HorizonConfiguration, +) +from fastmcp.cli.deploy.credentials import ( + AuthenticationRequiredError, + CredentialStore, + ResolvedCredential, +) +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_environment_logout, + emit_error, + emit_identity, + emit_logout, + start_device_approval_status, + stop_device_approval_status, +) +from fastmcp.cli.deploy.state import StateFileError, state_lock + +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 _load_session_snapshot( + credentials: CredentialStore, +) -> tuple[HorizonConfiguration, ResolvedCredential | None]: + with state_lock(credentials.path.parent): + configuration = ConfigurationStore(credentials.path.parent).load() + environment_key = os.environ.get("HORIZON_API_KEY") + if environment_key: + credential = ResolvedCredential( + api_key=SecretStr(environment_key), + source="environment", + ) + else: + stored_key = credentials.load() + credential = ( + ResolvedCredential(api_key=stored_key, source="stored") + if stored_key is not None + else None + ) + return configuration, credential + + +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() + requested_configuration: HorizonConfiguration | None = None + if host is not None: + try: + requested_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, + ) + + configuration, credential = _load_session_snapshot(credentials) + if ( + requested_configuration is not None + and configuration.api_origin != requested_configuration.api_origin + ): + raise StateFileError("The Horizon host changed during login") + + 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) + + async def interactive_credential() -> ResolvedCredential: + api_key = await device_authorization() + credentials.save_for_origin( + api_key, + expected_api_origin=configuration.api_origin, + ) + return ResolvedCredential(api_key=api_key, source="interactive") + + if credential is None: + credential = await interactive_credential() + + try: + user = await _get_user( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError: + if credential.source == "environment": + raise + + credentials.clear_if_matches( + credential.api_key, + expected_api_origin=configuration.api_origin, + ) + if credential.source == "interactive": + raise + + credential = await interactive_credential() + try: + user = await _get_user( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError: + credentials.clear_if_matches( + credential.api_key, + expected_api_origin=configuration.api_origin, + ) + 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() + configuration: HorizonConfiguration | None = None + credential: ResolvedCredential | None = None + + try: + configuration, credential = _load_session_snapshot(credentials) + if credential is None: + raise AuthenticationRequiredError("Horizon authentication is required") + user = await _get_user( + configuration.api_origin, + credential, + ) + except HorizonUnauthorizedError as error: + if ( + configuration is not None + and credential is not None + and credential.source == "stored" + ): + try: + credentials.clear_if_matches( + credential.api_key, + expected_api_origin=configuration.api_origin, + ) + 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() + + if os.environ.get("HORIZON_API_KEY"): + emit_environment_logout(json_output=json_output) + return + + try: + configuration, credential = _load_session_snapshot(credentials) + if credential is None: + emit_logout(remote_revoked=False, json_output=json_output) + return + + async with HorizonClient( + configuration.api_origin, + api_key=credential.api_key, + ) as client: + try: + await client.revoke_current_api_key() + finally: + credentials.clear_if_matches( + credential.api_key, + expected_api_origin=configuration.api_origin, + ) + 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/credentials.py b/fastmcp_slim/fastmcp/cli/deploy/credentials.py index bd129abee..8ec5f936c 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/credentials.py +++ b/fastmcp_slim/fastmcp/cli/deploy/credentials.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import secrets from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from pathlib import Path @@ -96,6 +97,32 @@ class CredentialStore: raise StateFileError("The Horizon host changed during login") self.save(api_key) + def clear_if_matches( + self, + api_key: SecretStr | str, + *, + expected_api_origin: str, + ) -> None: + """Clear a key only while its Horizon origin and value are active.""" + from fastmcp.cli.deploy.configuration import ConfigurationStore + + expected_api_origin = normalize_api_origin(expected_api_origin) + expected_api_key = ( + api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key + ) + with state_lock(self.path.parent): + active_api_origin = ConfigurationStore(self.path.parent).load().api_origin + active_api_key = self.load() + if ( + active_api_origin == expected_api_origin + and active_api_key is not None + and secrets.compare_digest( + active_api_key.get_secret_value(), + expected_api_key, + ) + ): + self.clear() + def clear(self) -> None: remove_state(self.path) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py new file mode 100644 index 000000000..2fbfaa2ba --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -0,0 +1,297 @@ +"""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_environment_logout(*, json_output: bool) -> None: + """Explain why logout cannot change an environment credential.""" + if json_output: + _write_json( + { + "ok": True, + "command": "logout", + "credentialSource": "environment", + "localCredentialRemoved": False, + "remoteRevoked": False, + } + ) + return + + message = Group( + Text("This session uses HORIZON_API_KEY.", style="bold"), + Text("Remove it from your environment to sign out."), + Text("No credential was revoked or removed.", style="dim"), + ) + console.print() + console.print( + Panel( + message, + title=Text("Horizon Account", style="bold cyan"), + title_align="left", + box=box.ROUNDED, + border_style="cyan", + padding=(1, 2), + width=60, + ) + ) + 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..ad2a3bb48 --- /dev/null +++ b/tests/cli/deploy/test_command.py @@ -0,0 +1,462 @@ +import json +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path +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.configuration import ConfigurationStore +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, + on_request: Callable[[httpx2.Request], None] | None = None, + ) -> None: + self.token_error = token_error + self.revoke_status = revoke_status + self.invalid_api_key = invalid_api_key + self.on_request = on_request + self.requests: list[httpx2.Request] = [] + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + self.requests.append(request) + if self.on_request is not None: + self.on_request(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 + + +def test_session_snapshot_reads_host_and_credential_under_one_lock( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + configuration_load = ConfigurationStore.load + credential_load = CredentialStore.load + + @contextmanager + def lock(directory: Path) -> Iterator[None]: + events.append("lock") + yield + events.append("unlock") + + def load_configuration(store: ConfigurationStore): + events.append("configuration") + return configuration_load(store) + + def load_credential(store: CredentialStore): + events.append("credential") + return credential_load(store) + + monkeypatch.setattr(command_module, "state_lock", lock) + monkeypatch.setattr(ConfigurationStore, "load", load_configuration) + monkeypatch.setattr(CredentialStore, "load", load_credential) + + configuration, credential = command_module._load_session_snapshot(CredentialStore()) + + assert configuration.api_origin == "https://horizon.prefect.io" + assert credential is None + assert events == ["lock", "configuration", "credential", "unlock"] + + +@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_whoami_does_not_clear_a_newer_host_credential( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], +) -> None: + credentials = CredentialStore() + credentials.save("fmcp_stale_key") + switched = False + + def switch_host(request: httpx2.Request) -> None: + nonlocal switched + if request.url.path != "/api/v0/me" or switched: + return + switched = True + ConfigurationStore().set_api_origin( + "https://dev.horizon.prefect.io", + credentials=credentials, + ) + credentials.save_for_origin( + "fmcp_new_key", + expected_api_origin="https://dev.horizon.prefect.io", + ) + + api = HorizonAuthAPI( + invalid_api_key="fmcp_stale_key", + on_request=switch_host, + ) + use_horizon_api(api) + + with pytest.raises(SystemExit, match="1"): + await whoami(json_output=True) + + result = json.loads(capsys.readouterr().out) + assert result["error"]["category"] == "authentication_invalid" + assert ConfigurationStore().load().api_origin == ("https://dev.horizon.prefect.io") + stored_key = credentials.load() + assert stored_key is not None + assert stored_key.get_secret_value() == "fmcp_new_key" + assert api.requests[0].url.host == "horizon.prefect.io" + + +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_does_not_modify_environment_or_stored_credentials( + use_horizon_api: Callable[[HorizonAuthAPI], None], + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = HorizonAuthAPI() + use_horizon_api(api) + monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key") + CredentialStore().save("fmcp_stored_key") + + await logout(json_output=True) + + assert json.loads(capsys.readouterr().out) == { + "ok": True, + "command": "logout", + "credentialSource": "environment", + "localCredentialRemoved": False, + "remoteRevoked": False, + } + stored_key = CredentialStore().load() + assert stored_key is not None + assert stored_key.get_secret_value() == "fmcp_stored_key" + assert api.requests == [] + + +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_credentials.py b/tests/cli/deploy/test_credentials.py index 23e98ca62..f62b3dc02 100644 --- a/tests/cli/deploy/test_credentials.py +++ b/tests/cli/deploy/test_credentials.py @@ -183,6 +183,29 @@ async def test_interactive_credential_rejects_an_origin_change( assert store.load() is None +def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_current") + + store.clear_if_matches( + "fmcp_different", + expected_api_origin="https://horizon.prefect.io", + ) + store.clear_if_matches( + "fmcp_current", + expected_api_origin="https://dev.horizon.prefect.io", + ) + + assert load_secret(store).get_secret_value() == "fmcp_current" + + store.clear_if_matches( + "fmcp_current", + expected_api_origin="https://horizon.prefect.io", + ) + + assert store.load() is None + + async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None: with pytest.raises(AuthenticationRequiredError): await resolve_credential(CredentialStore(tmp_path), environ={}) diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py new file mode 100644 index 000000000..d445cdaf0 --- /dev/null +++ b/tests/cli/deploy/test_output.py @@ -0,0 +1,167 @@ +import json + +import pytest + +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser +from fastmcp.cli.deploy.output import ( + emit_device_challenge, + emit_environment_logout, + 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_environment_logout_explains_that_no_action_was_taken( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_environment_logout(json_output=False) + + output = capsys.readouterr().out + assert "Horizon Account" in output + assert "This session uses HORIZON_API_KEY." in output + assert "Remove it from your environment to sign out." in output + assert "No credential was revoked or removed." in output + + +def test_json_environment_logout_has_stable_fields( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_environment_logout(json_output=True) + + assert json.loads(capsys.readouterr().out) == { + "ok": True, + "command": "logout", + "credentialSource": "environment", + "localCredentialRemoved": False, + "remoteRevoked": False, + } + + +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."""