From 11f450932c4db16f3cda594614e846ca6f8f8432 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 21:30:18 -0700 Subject: [PATCH 01/10] feat: add Horizon authentication client and state --- fastmcp_slim/fastmcp/cli/deploy/__init__.py | 1 + .../fastmcp/cli/deploy/authentication.py | 101 ++++++ .../fastmcp/cli/deploy/configuration.py | 67 ++++ .../fastmcp/cli/deploy/credentials.py | 121 +++++++ .../fastmcp/cli/deploy/horizon_client.py | 328 ++++++++++++++++++ fastmcp_slim/fastmcp/cli/deploy/state.py | 168 +++++++++ tests/cli/deploy/test_authentication.py | 176 ++++++++++ tests/cli/deploy/test_configuration.py | 131 +++++++ tests/cli/deploy/test_credentials.py | 252 ++++++++++++++ tests/cli/deploy/test_horizon_client.py | 246 +++++++++++++ 10 files changed, 1591 insertions(+) create mode 100644 fastmcp_slim/fastmcp/cli/deploy/__init__.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/authentication.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/configuration.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/credentials.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/horizon_client.py create mode 100644 fastmcp_slim/fastmcp/cli/deploy/state.py create mode 100644 tests/cli/deploy/test_authentication.py create mode 100644 tests/cli/deploy/test_configuration.py create mode 100644 tests/cli/deploy/test_credentials.py create mode 100644 tests/cli/deploy/test_horizon_client.py diff --git a/fastmcp_slim/fastmcp/cli/deploy/__init__.py b/fastmcp_slim/fastmcp/cli/deploy/__init__.py new file mode 100644 index 000000000..94b708080 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/__init__.py @@ -0,0 +1 @@ +"""Horizon deployment support for the FastMCP CLI.""" diff --git a/fastmcp_slim/fastmcp/cli/deploy/authentication.py b/fastmcp_slim/fastmcp/cli/deploy/authentication.py new file mode 100644 index 000000000..6e71e5935 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/authentication.py @@ -0,0 +1,101 @@ +"""Horizon device authorization workflow.""" + +from __future__ import annotations + +import asyncio +import time +import webbrowser +from collections.abc import Awaitable, Callable +from contextlib import suppress + +from pydantic import SecretStr + +from fastmcp.cli.deploy.horizon_client import ( + DeviceAuthorization, + DeviceMetadata, + HorizonClient, +) + + +class DeviceAuthorizationError(RuntimeError): + """Device authorization did not complete.""" + + +class DeviceAuthorizationDeniedError(DeviceAuthorizationError): + """The user denied the device authorization request.""" + + +class DeviceAuthorizationExpiredError(DeviceAuthorizationError): + """The device authorization request expired.""" + + +async def poll_device_authorization( + client: HorizonClient, + authorization: DeviceAuthorization, + *, + sleep: Callable[[float], Awaitable[None]] | None = None, + monotonic: Callable[[], float] = time.monotonic, +) -> SecretStr: + """Poll at the server interval until the device request completes.""" + sleep = asyncio.sleep if sleep is None else sleep + deadline = monotonic() + authorization.expires_in + interval = float(authorization.interval) + + while True: + remaining = deadline - monotonic() + if remaining <= 0: + raise DeviceAuthorizationExpiredError( + "The device authorization request expired" + ) + + await sleep(min(interval, remaining)) + if monotonic() >= deadline: + raise DeviceAuthorizationExpiredError( + "The device authorization request expired" + ) + + result = await client.exchange_device_authorization(authorization.device_code) + if result.access_token is not None: + return result.access_token + if result.error == "authorization_pending": + continue + if result.error == "slow_down": + interval += 5 + continue + if result.error == "access_denied": + raise DeviceAuthorizationDeniedError( + "The device authorization request was denied" + ) + if result.error == "expired_token": + raise DeviceAuthorizationExpiredError( + "The device authorization request expired" + ) + + raise DeviceAuthorizationError("Device authorization failed") + + +async def authorize_device( + client: HorizonClient, + *, + metadata: DeviceMetadata | None = None, + on_challenge: Callable[[DeviceAuthorization], None] | None = None, + open_browser: bool = False, + browser_opener: Callable[[str], object] = webbrowser.open, + sleep: Callable[[float], Awaitable[None]] | None = None, + monotonic: Callable[[], float] = time.monotonic, +) -> SecretStr: + """Create, present, and complete a Horizon device authorization.""" + authorization = await client.create_device_authorization(metadata) + if on_challenge is not None: + on_challenge(authorization) + + if open_browser: + with suppress(OSError, webbrowser.Error): + browser_opener(authorization.verification_uri_complete) + + return await poll_device_authorization( + client, + authorization, + sleep=sleep, + monotonic=monotonic, + ) diff --git a/fastmcp_slim/fastmcp/cli/deploy/configuration.py b/fastmcp_slim/fastmcp/cli/deploy/configuration.py new file mode 100644 index 000000000..9f2f41ca1 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/configuration.py @@ -0,0 +1,67 @@ +"""Global non-secret configuration for the FastMCP CLI.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from fastmcp.cli.deploy.credentials import CredentialStore +from fastmcp.cli.deploy.horizon_client import ( + DEFAULT_HORIZON_API_ORIGIN, + normalize_api_origin, +) +from fastmcp.cli.deploy.state import read_state, write_state + + +class HorizonConfiguration(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + schema_version: Literal[1] = Field(alias="schemaVersion") + api_origin: str = Field(alias="apiOrigin") + + @field_validator("api_origin") + @classmethod + def validate_api_origin(cls, value: str) -> str: + return normalize_api_origin(value) + + +class ConfigurationStore: + """Persist the Horizon API origin without organization state.""" + + def __init__(self, state_directory: Path | None = None) -> None: + if state_directory is None: + import fastmcp + + state_directory = fastmcp.settings.home / "cli" + self.path = state_directory / "config.json" + + def load(self) -> HorizonConfiguration: + state = read_state(self.path, HorizonConfiguration) + if state is not None: + return state + return HorizonConfiguration( + schemaVersion=1, + apiOrigin=DEFAULT_HORIZON_API_ORIGIN, + ) + + def save(self, configuration: HorizonConfiguration) -> None: + write_state( + self.path, + configuration.model_dump(mode="json", by_alias=True), + ) + + def set_api_origin( + self, + api_origin: str, + *, + credentials: CredentialStore, + ) -> HorizonConfiguration: + """Set the origin and clear credentials before an origin change.""" + current = self.load() + updated = HorizonConfiguration(schemaVersion=1, apiOrigin=api_origin) + if updated.api_origin != current.api_origin: + credentials.clear() + self.save(updated) + return updated diff --git a/fastmcp_slim/fastmcp/cli/deploy/credentials.py b/fastmcp_slim/fastmcp/cli/deploy/credentials.py new file mode 100644 index 000000000..c70dbd3f8 --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/credentials.py @@ -0,0 +1,121 @@ +"""Restricted Horizon credential storage and resolution.""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + ValidationError, + field_validator, +) + +from fastmcp.cli.deploy.horizon_client import HorizonClient +from fastmcp.cli.deploy.state import ( + StateFileError, + read_state, + remove_state, + write_state, +) + +CredentialSource = Literal["environment", "stored", "interactive"] + + +class AuthenticationRequiredError(RuntimeError): + """No Horizon credential is available without interactive authorization.""" + + +class AuthState(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + schema_version: Literal[1] = Field(alias="schemaVersion") + api_key: SecretStr = Field(alias="apiKey") + + @field_validator("api_key") + @classmethod + def require_nonempty_api_key(cls, value: SecretStr) -> SecretStr: + if not value.get_secret_value().strip(): + raise ValueError("The API key is empty") + return value + + +@dataclass(frozen=True) +class ResolvedCredential: + api_key: SecretStr + source: CredentialSource + + +class CredentialStore: + """Persist the active personal Horizon API key.""" + + def __init__(self, state_directory: Path | None = None) -> None: + if state_directory is None: + import fastmcp + + state_directory = fastmcp.settings.home / "cli" + self.path = state_directory / "auth.json" + + def load(self) -> SecretStr | None: + state = read_state(self.path, AuthState, secret=True) + return state.api_key if state is not None else None + + def save(self, api_key: SecretStr | str) -> None: + try: + state = AuthState(schemaVersion=1, apiKey=api_key) + except ValidationError: + raise StateFileError("The Horizon API key is invalid") from None + write_state( + self.path, + { + "schemaVersion": state.schema_version, + "apiKey": state.api_key.get_secret_value(), + }, + ) + + def clear(self) -> None: + remove_state(self.path) + + +async def resolve_credential( + store: CredentialStore, + *, + environ: Mapping[str, str] | None = None, + authorize: Callable[[], Awaitable[SecretStr]] | None = None, +) -> ResolvedCredential: + """Resolve environment, stored, then interactive credentials.""" + environ = os.environ if environ is None else environ + environment_key = environ.get("HORIZON_API_KEY") + if environment_key: + return ResolvedCredential( + api_key=SecretStr(environment_key), + source="environment", + ) + + stored_key = store.load() + if stored_key is not None: + return ResolvedCredential(api_key=stored_key, source="stored") + + if authorize is None: + raise AuthenticationRequiredError("Horizon authentication is required") + + api_key = await authorize() + store.save(api_key) + return ResolvedCredential(api_key=api_key, source="interactive") + + +async def revoke_and_clear_credential( + client: HorizonClient, + store: CredentialStore, +) -> None: + """Attempt remote revocation and always remove the stored credential.""" + try: + await client.revoke_current_api_key() + finally: + store.clear() diff --git a/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py new file mode 100644 index 000000000..26b609e6d --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py @@ -0,0 +1,328 @@ +"""Typed HTTP client for the Horizon control plane.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import TracebackType +from typing import Annotated, Literal, TypeVar +from urllib.parse import urlsplit, urlunsplit + +import httpx2 +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + ValidationError, + field_validator, +) + +DEVICE_AUTH_CLIENT_ID = "fastmcp-cli" +DEVICE_AUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" +DEFAULT_HORIZON_API_ORIGIN = "https://horizon.prefect.io" + +DeviceTokenError = Literal[ + "authorization_pending", + "slow_down", + "access_denied", + "expired_token", +] + + +class HorizonError(RuntimeError): + """A safe Horizon client error.""" + + +class HorizonUnavailableError(HorizonError): + """The Horizon API could not be reached.""" + + +class HorizonUnauthorizedError(HorizonError): + """The Horizon credential was rejected.""" + + +class HorizonResponseError(HorizonError): + """Horizon returned an unexpected response.""" + + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class _ResponseModel(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + +ResponseModelT = TypeVar("ResponseModelT", bound=_ResponseModel) + + +class DeviceAuthorization(_ResponseModel): + device_code: Annotated[str, Field(min_length=1)] + user_code: Annotated[str, Field(min_length=1)] + verification_uri: Annotated[str, Field(pattern=r"^https?://")] + verification_uri_complete: Annotated[str, Field(pattern=r"^https?://")] + expires_in: Annotated[int, Field(gt=0)] + interval: Annotated[int, Field(gt=0)] + + +class DeviceAccessToken(_ResponseModel): + access_token: SecretStr + token_type: Literal["Bearer"] + + @field_validator("access_token") + @classmethod + def require_nonempty_access_token(cls, value: SecretStr) -> SecretStr: + if not value.get_secret_value().strip(): + raise ValueError("The access token is empty") + return value + + +class _DeviceTokenErrorResponse(_ResponseModel): + error: DeviceTokenError + + +class HorizonUser(_ResponseModel): + id: str + email: str + name: str | None + + +class _CurrentUserResponse(_ResponseModel): + user: HorizonUser + + +class HorizonOrganization(_ResponseModel): + id: str + name: str + slug: str + + +class _PaginationMeta(_ResponseModel): + nextCursor: str | None + limit: int + + +class _OrganizationsResponse(_ResponseModel): + items: tuple[HorizonOrganization, ...] + meta: _PaginationMeta + + +@dataclass(frozen=True) +class DeviceMetadata: + device_name: str | None = None + platform: str | None = None + architecture: str | None = None + client_version: str | None = None + + +@dataclass(frozen=True) +class DeviceTokenPoll: + access_token: SecretStr | None = None + error: DeviceTokenError | None = None + + def __post_init__(self) -> None: + if (self.access_token is None) == (self.error is None): + raise ValueError("A device token poll must contain one result") + + +def normalize_api_origin(value: str) -> str: + """Validate and normalize a Horizon API origin.""" + parts = urlsplit(value) + if ( + parts.scheme not in {"http", "https"} + or not parts.hostname + or parts.username is not None + or parts.password is not None + or parts.query + or parts.fragment + or parts.path not in {"", "/"} + ): + raise ValueError("The Horizon API origin must be an HTTP origin") + + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + +class HorizonClient: + """Call the Horizon routes used by FastMCP CLI authentication.""" + + def __init__( + self, + api_origin: str = DEFAULT_HORIZON_API_ORIGIN, + *, + api_key: SecretStr | str | None = None, + transport: httpx2.AsyncBaseTransport | None = None, + timeout: float = 30.0, + ) -> None: + self.api_origin = normalize_api_origin(api_origin) + self._api_key = ( + api_key + if isinstance(api_key, SecretStr) + else SecretStr(api_key) + if api_key is not None + else None + ) + self._client = httpx2.AsyncClient( + base_url=self.api_origin, + follow_redirects=False, + timeout=timeout, + transport=transport, + ) + + async def __aenter__(self) -> HorizonClient: + await self._client.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self._client.__aexit__(exc_type, exc_value, traceback) + + async def aclose(self) -> None: + await self._client.aclose() + + async def _request( + self, + method: str, + path: str, + *, + authenticated: bool = False, + data: Mapping[str, str] | None = None, + params: Mapping[str, str | int] | None = None, + ) -> httpx2.Response: + headers: dict[str, str] = {} + if authenticated: + if self._api_key is None: + raise HorizonUnauthorizedError("Horizon authentication is required") + headers["Authorization"] = f"Bearer {self._api_key.get_secret_value()}" + + try: + response = await self._client.request( + method, + path, + headers=headers, + data=data, + params=params, + ) + except httpx2.RequestError as exc: + raise HorizonUnavailableError("The Horizon API is unavailable") from exc + + if response.status_code == 401: + raise HorizonUnauthorizedError("The Horizon credential is not valid") + return response + + @staticmethod + def _validate_response( + response: httpx2.Response, + model: type[ResponseModelT], + ) -> ResponseModelT: + try: + return model.model_validate_json(response.content) + except (ValidationError, ValueError): + raise HorizonResponseError( + "Horizon returned an invalid response", + status_code=response.status_code, + ) from None + + @staticmethod + def _require_status(response: httpx2.Response, expected: int) -> None: + if response.status_code != expected: + raise HorizonResponseError( + "Horizon returned an unexpected status", + status_code=response.status_code, + ) + + async def create_device_authorization( + self, + metadata: DeviceMetadata | None = None, + ) -> DeviceAuthorization: + metadata = metadata or DeviceMetadata() + form = { + "client_id": DEVICE_AUTH_CLIENT_ID, + "device_name": metadata.device_name, + "platform": metadata.platform, + "architecture": metadata.architecture, + "client_version": metadata.client_version, + } + response = await self._request( + "POST", + "/api/v0/oauth/device/authorization", + data={key: value for key, value in form.items() if value is not None}, + ) + self._require_status(response, 200) + return self._validate_response(response, DeviceAuthorization) + + async def exchange_device_authorization( + self, + device_code: str, + ) -> DeviceTokenPoll: + response = await self._request( + "POST", + "/api/v0/oauth/device/token", + data={ + "grant_type": DEVICE_AUTH_GRANT_TYPE, + "client_id": DEVICE_AUTH_CLIENT_ID, + "device_code": device_code, + }, + ) + + if response.status_code == 200: + result = self._validate_response(response, DeviceAccessToken) + return DeviceTokenPoll(access_token=result.access_token) + + if response.status_code == 400: + result = self._validate_response(response, _DeviceTokenErrorResponse) + return DeviceTokenPoll(error=result.error) + + self._require_status(response, 200) + raise AssertionError("unreachable") + + async def get_current_user(self) -> HorizonUser: + response = await self._request( + "GET", + "/api/v0/me", + authenticated=True, + ) + self._require_status(response, 200) + result = self._validate_response(response, _CurrentUserResponse) + return result.user + + async def list_organizations(self) -> tuple[HorizonOrganization, ...]: + organizations: list[HorizonOrganization] = [] + cursor: str | None = None + seen_cursors: set[str] = set() + + while True: + params = {"limit": 100} + if cursor is not None: + params["cursor"] = cursor + response = await self._request( + "GET", + "/api/v0/me/organizations", + authenticated=True, + params=params, + ) + self._require_status(response, 200) + result = self._validate_response(response, _OrganizationsResponse) + organizations.extend(result.items) + + cursor = result.meta.nextCursor + if cursor is None: + return tuple(organizations) + if cursor in seen_cursors: + raise HorizonResponseError( + "Horizon returned an invalid organization cursor", + status_code=response.status_code, + ) + seen_cursors.add(cursor) + + async def revoke_current_api_key(self) -> None: + response = await self._request( + "DELETE", + "/api/v0/me/api-key", + authenticated=True, + ) + self._require_status(response, 204) diff --git a/fastmcp_slim/fastmcp/cli/deploy/state.py b/fastmcp_slim/fastmcp/cli/deploy/state.py new file mode 100644 index 000000000..3ee77efcc --- /dev/null +++ b/fastmcp_slim/fastmcp/cli/deploy/state.py @@ -0,0 +1,168 @@ +"""Versioned JSON state helpers for the FastMCP CLI.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +from contextlib import suppress +from pathlib import Path +from typing import Any, TypeVar + +from pydantic import BaseModel, ValidationError + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class StateFileError(RuntimeError): + """A CLI state file could not be read or written safely.""" + + +_WINDOWS_ACL_SCRIPT = r""" +$ErrorActionPreference = "Stop" +$path = $args[0] +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + +if ([System.IO.Directory]::Exists($path)) { + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit ` + -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) +} else { + $acl = [System.Security.AccessControl.FileSecurity]::new() + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow + ) +} + +$acl.SetOwner($sid) +$acl.SetAccessRuleProtection($true, $false) +$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $path -AclObject $acl +""" + + +def _restrict_windows_access(path: Path) -> None: + try: + subprocess.run( + [ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + _WINDOWS_ACL_SCRIPT, + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise StateFileError("Could not restrict access to CLI state") from exc + + +def _restrict_access(path: Path, *, directory: bool = False) -> None: + try: + if os.name == "nt": + _restrict_windows_access(path) + else: + path.chmod(0o700 if directory else 0o600) + except OSError as exc: + raise StateFileError("Could not restrict access to CLI state") from exc + + +def _prepare_directory(path: Path) -> None: + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise StateFileError("Could not create the CLI state directory") from exc + _restrict_access(path, directory=True) + + +def read_state( + path: Path, + model: type[ModelT], + *, + secret: bool = False, +) -> ModelT | None: + """Read and validate a versioned JSON state file.""" + if not path.exists(): + return None + if path.is_symlink(): + raise StateFileError(f"CLI state must not be a symbolic link: {path.name}") + + if secret: + _restrict_access(path.parent, directory=True) + _restrict_access(path) + + try: + return model.model_validate_json(path.read_text(encoding="utf-8")) + except (ValidationError, ValueError): + raise StateFileError(f"CLI state is invalid: {path.name}") from None + except OSError as exc: + raise StateFileError(f"Could not read CLI state: {path.name}") from exc + + +def write_state(path: Path, data: dict[str, Any]) -> None: + """Write JSON through a restricted temporary file and atomic replacement.""" + _prepare_directory(path.parent) + payload = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode() + descriptor: int | None = None + temporary_path: Path | None = None + + try: + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary_path = Path(temporary_name) + if os.name != "nt": + os.fchmod(descriptor, 0o600) + + temporary_file = os.fdopen(descriptor, "wb") + descriptor = None + with temporary_file: + temporary_file.write(payload) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + + _restrict_access(temporary_path) + os.replace(temporary_path, path) + temporary_path = None + + if os.name != "nt": + directory_descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + except StateFileError: + raise + except OSError as exc: + raise StateFileError(f"Could not write CLI state: {path.name}") from exc + finally: + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + if temporary_path is not None: + with suppress(OSError): + temporary_path.unlink(missing_ok=True) + + +def remove_state(path: Path) -> None: + """Remove a state file when it exists.""" + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise StateFileError(f"Could not remove CLI state: {path.name}") from exc diff --git a/tests/cli/deploy/test_authentication.py b/tests/cli/deploy/test_authentication.py new file mode 100644 index 000000000..4b9b215cc --- /dev/null +++ b/tests/cli/deploy/test_authentication.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import httpx2 +import pytest + +from fastmcp.cli.deploy.authentication import ( + DeviceAuthorizationDeniedError, + DeviceAuthorizationExpiredError, + authorize_device, + poll_device_authorization, +) +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonClient + + +class Clock: + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def monotonic(self) -> float: + return self.now + + async def sleep(self, delay: float) -> None: + self.sleeps.append(delay) + self.now += delay + + +def authorization(*, expires_in: int = 600, interval: int = 5) -> DeviceAuthorization: + return DeviceAuthorization( + device_code="device-secret", + user_code="BCDF-GHJK", + verification_uri="https://horizon.prefect.io/oauth/device", + verification_uri_complete=( + "https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK" + ), + expires_in=expires_in, + interval=interval, + ) + + +def sequenced_transport( + responses: list[httpx2.Response], +) -> httpx2.MockTransport: + def handler(request: httpx2.Request) -> httpx2.Response: + return responses.pop(0) + + return httpx2.MockTransport(handler) + + +async def test_polling_handles_pending_slow_down_and_approval() -> None: + clock = Clock() + async with HorizonClient( + transport=sequenced_transport( + [ + httpx2.Response(400, json={"error": "authorization_pending"}), + httpx2.Response(400, json={"error": "slow_down"}), + httpx2.Response( + 200, + json={"access_token": "fmcp_secret", "token_type": "Bearer"}, + ), + ] + ) + ) as client: + api_key = await poll_device_authorization( + client, + authorization(), + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + assert api_key.get_secret_value() == "fmcp_secret" + assert clock.sleeps == [5, 5, 10] + + +@pytest.mark.parametrize( + ("error", "exception"), + [ + ("access_denied", DeviceAuthorizationDeniedError), + ("expired_token", DeviceAuthorizationExpiredError), + ], +) +async def test_polling_handles_terminal_errors( + error: str, + exception: type[Exception], +) -> None: + clock = Clock() + async with HorizonClient( + transport=sequenced_transport([httpx2.Response(400, json={"error": error})]) + ) as client: + with pytest.raises(exception): + await poll_device_authorization( + client, + authorization(), + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + +async def test_polling_stops_at_the_local_expiry_deadline() -> None: + clock = Clock() + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(400, json={"error": "authorization_pending"}) + + async with HorizonClient(transport=httpx2.MockTransport(handler)) as client: + with pytest.raises(DeviceAuthorizationExpiredError): + await poll_device_authorization( + client, + authorization(expires_in=5, interval=5), + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + assert requests == [] + + +async def test_authorize_device_presents_challenge_before_opening_browser() -> None: + events: list[str] = [] + clock = Clock() + + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/authorization"): + return httpx2.Response(200, json=authorization().model_dump()) + return httpx2.Response( + 200, + json={"access_token": "fmcp_secret", "token_type": "Bearer"}, + ) + + def present(challenge: DeviceAuthorization) -> None: + events.append(f"present:{challenge.user_code}") + + def open_browser(url: str) -> None: + events.append(f"browser:{url}") + + async with HorizonClient(transport=httpx2.MockTransport(handler)) as client: + await authorize_device( + client, + on_challenge=present, + open_browser=True, + browser_opener=open_browser, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + assert events == [ + "present:BCDF-GHJK", + "browser:https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK", + ] + + +async def test_browser_failure_does_not_stop_remote_login() -> None: + clock = Clock() + + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/authorization"): + return httpx2.Response(200, json=authorization().model_dump()) + return httpx2.Response( + 200, + json={"access_token": "fmcp_secret", "token_type": "Bearer"}, + ) + + def fail_to_open(url: str) -> None: + raise OSError("no browser") + + async with HorizonClient(transport=httpx2.MockTransport(handler)) as client: + api_key = await authorize_device( + client, + open_browser=True, + browser_opener=fail_to_open, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + assert api_key.get_secret_value() == "fmcp_secret" diff --git a/tests/cli/deploy/test_configuration.py b/tests/cli/deploy/test_configuration.py new file mode 100644 index 000000000..476e68b89 --- /dev/null +++ b/tests/cli/deploy/test_configuration.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from fastmcp.cli.deploy.configuration import ( + ConfigurationStore, + HorizonConfiguration, +) +from fastmcp.cli.deploy.credentials import CredentialStore +from fastmcp.cli.deploy.horizon_client import DEFAULT_HORIZON_API_ORIGIN +from fastmcp.cli.deploy.state import StateFileError + + +def test_configuration_defaults_to_the_production_origin(tmp_path: Path) -> None: + store = ConfigurationStore(tmp_path) + + configuration = store.load() + + assert configuration.api_origin == DEFAULT_HORIZON_API_ORIGIN + assert store.path.exists() is False + + +def test_configuration_stores_only_schema_and_api_origin(tmp_path: Path) -> None: + store = ConfigurationStore(tmp_path) + configuration = HorizonConfiguration( + schemaVersion=1, + apiOrigin="https://example.com/", + ) + + store.save(configuration) + + assert json.loads(store.path.read_text()) == { + "schemaVersion": 1, + "apiOrigin": "https://example.com", + } + assert store.load() == configuration + + +def test_configuration_rejects_organization_state(tmp_path: Path) -> None: + store = ConfigurationStore(tmp_path) + store.path.write_text( + json.dumps( + { + "schemaVersion": 1, + "apiOrigin": DEFAULT_HORIZON_API_ORIGIN, + "currentOrganizationId": "org-id", + } + ) + ) + + with pytest.raises(StateFileError): + store.load() + + +def test_origin_change_clears_credentials_before_writing_configuration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credentials = CredentialStore(tmp_path) + credentials.save("fmcp_secret") + configuration = ConfigurationStore(tmp_path) + configuration.save( + HorizonConfiguration( + schemaVersion=1, + apiOrigin=DEFAULT_HORIZON_API_ORIGIN, + ) + ) + events: list[str] = [] + original_clear = credentials.clear + original_save = configuration.save + + def clear() -> None: + events.append("clear") + original_clear() + + def save(value: HorizonConfiguration) -> None: + events.append("save") + original_save(value) + + monkeypatch.setattr(credentials, "clear", clear) + monkeypatch.setattr(configuration, "save", save) + + result = configuration.set_api_origin( + "https://dev.horizon.prefect.io", + credentials=credentials, + ) + + assert events == ["clear", "save"] + assert credentials.load() is None + assert result.api_origin == "https://dev.horizon.prefect.io" + + +def test_same_origin_does_not_clear_credentials(tmp_path: Path) -> None: + credentials = CredentialStore(tmp_path) + credentials.save("fmcp_secret") + configuration = ConfigurationStore(tmp_path) + + configuration.set_api_origin( + DEFAULT_HORIZON_API_ORIGIN, + credentials=credentials, + ) + + stored = credentials.load() + assert stored is not None + assert stored.get_secret_value() == "fmcp_secret" + + +def test_failed_origin_write_leaves_no_cross_origin_credential( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credentials = CredentialStore(tmp_path) + credentials.save("fmcp_secret") + configuration = ConfigurationStore(tmp_path) + + def fail_save(value: HorizonConfiguration) -> None: + raise StateFileError("write failed") + + monkeypatch.setattr(configuration, "save", fail_save) + + with pytest.raises(StateFileError): + configuration.set_api_origin( + "https://dev.horizon.prefect.io", + credentials=credentials, + ) + + assert credentials.load() is None + assert configuration.load().api_origin == DEFAULT_HORIZON_API_ORIGIN diff --git a/tests/cli/deploy/test_credentials.py b/tests/cli/deploy/test_credentials.py new file mode 100644 index 000000000..bd6f8c514 --- /dev/null +++ b/tests/cli/deploy/test_credentials.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import json +import os +import subprocess +import traceback +from pathlib import Path + +import httpx2 +import pytest +from pydantic import SecretStr + +from fastmcp.cli.deploy.credentials import ( + AuthenticationRequiredError, + CredentialStore, + resolve_credential, + revoke_and_clear_credential, +) +from fastmcp.cli.deploy.horizon_client import HorizonClient, HorizonUnavailableError +from fastmcp.cli.deploy.state import ( + StateFileError, + _restrict_windows_access, +) + + +def load_secret(store: CredentialStore) -> SecretStr: + secret = store.load() + assert secret is not None + return secret + + +def test_credential_store_writes_only_the_approved_contract(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_secret") + + assert json.loads(store.path.read_text()) == { + "schemaVersion": 1, + "apiKey": "fmcp_secret", + } + assert load_secret(store).get_secret_value() == "fmcp_secret" + assert "user" not in store.path.read_text() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") +def test_credential_store_restricts_file_and_directory_modes(tmp_path: Path) -> None: + state_directory = tmp_path / "cli" + store = CredentialStore(state_directory) + store.save("fmcp_secret") + + assert store.path.stat().st_mode & 0o777 == 0o600 + assert state_directory.stat().st_mode & 0o777 == 0o700 + + +def test_credential_store_restricts_an_existing_secret_file(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + store.path.write_text('{"schemaVersion": 1, "apiKey": "fmcp_secret"}') + if os.name != "nt": + store.path.chmod(0o644) + + assert load_secret(store).get_secret_value() == "fmcp_secret" + if os.name != "nt": + assert store.path.stat().st_mode & 0o777 == 0o600 + + +def test_atomic_write_preserves_previous_state_on_replace_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_original") + + def fail_replace(source: Path, destination: Path) -> None: + raise OSError("replace failed") + + monkeypatch.setattr("fastmcp.cli.deploy.state.os.replace", fail_replace) + with pytest.raises(StateFileError): + store.save("fmcp_new") + + assert json.loads(store.path.read_text())["apiKey"] == "fmcp_original" + assert list(tmp_path.glob(".*.tmp")) == [] + + +async def test_environment_credential_takes_precedence_and_is_not_stored( + tmp_path: Path, +) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_stored") + authorize_called = False + + async def authorize() -> SecretStr: + nonlocal authorize_called + authorize_called = True + return SecretStr("fmcp_interactive") + + result = await resolve_credential( + store, + environ={"HORIZON_API_KEY": "fmcp_environment"}, + authorize=authorize, + ) + + assert result.source == "environment" + assert result.api_key.get_secret_value() == "fmcp_environment" + assert load_secret(store).get_secret_value() == "fmcp_stored" + assert authorize_called is False + + +async def test_stored_credential_precedes_interactive_authorization( + tmp_path: Path, +) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_stored") + + async def authorize() -> SecretStr: + raise AssertionError("interactive authorization must not run") + + result = await resolve_credential(store, environ={}, authorize=authorize) + + assert result.source == "stored" + assert result.api_key.get_secret_value() == "fmcp_stored" + + +async def test_interactive_credential_is_persisted(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + + async def authorize() -> SecretStr: + return SecretStr("fmcp_interactive") + + result = await resolve_credential(store, environ={}, authorize=authorize) + + assert result.source == "interactive" + assert load_secret(store).get_secret_value() == "fmcp_interactive" + + +async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None: + with pytest.raises(AuthenticationRequiredError): + await resolve_credential(CredentialStore(tmp_path), environ={}) + + +async def test_remote_revoke_always_removes_the_local_credential( + tmp_path: Path, +) -> None: + store = CredentialStore(tmp_path) + store.save("fmcp_stored") + + def unavailable(request: httpx2.Request) -> httpx2.Response: + raise httpx2.ConnectError("offline", request=request) + + async with HorizonClient( + api_key="fmcp_stored", + transport=httpx2.MockTransport(unavailable), + ) as client: + with pytest.raises(HorizonUnavailableError): + await revoke_and_clear_credential(client, store) + + assert store.load() is None + + +def test_windows_acl_replaces_the_existing_access_list( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "auth.json" + path.write_text("{}") + calls: list[list[str]] = [] + + def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("fastmcp.cli.deploy.state.subprocess.run", run) + _restrict_windows_access(path) + + assert calls == [ + [ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + calls[0][5], + str(path), + ] + ] + assert "FileSecurity]::new()" in calls[0][5] + assert "SetAccessRuleProtection($true, $false)" in calls[0][5] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows ACL inspection") +def test_windows_credential_state_allows_only_the_current_user(tmp_path: Path) -> None: + state_directory = tmp_path / "cli" + store = CredentialStore(state_directory) + store.save("fmcp_secret") + inspect_acl = r""" +$acl = Get-Acl -LiteralPath $args[0] +$current = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$access = @($acl.Access | ForEach-Object { + $_.IdentityReference.Translate( + [System.Security.Principal.SecurityIdentifier] + ).Value +}) +[pscustomobject]@{ + current = $current + access = $access + protected = $acl.AreAccessRulesProtected + inherited = @($acl.Access | ForEach-Object { $_.IsInherited }) +} | ConvertTo-Json -Compress +""" + + for path in (state_directory, store.path): + result = subprocess.run( + [ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + inspect_acl, + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + acl = json.loads(result.stdout) + assert set(acl["access"]) == {acl["current"]} + assert acl["protected"] is True + assert not any(acl["inherited"]) + + +def test_credential_store_rejects_empty_api_keys(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + + for api_key in ("", " "): + with pytest.raises(StateFileError): + store.save(api_key) + + assert store.path.exists() is False + + +def test_malformed_credential_state_has_a_safe_error(tmp_path: Path) -> None: + store = CredentialStore(tmp_path) + store.path.write_text( + '{"schemaVersion": 1, "apiKey": "fmcp_valid", "metadata": "fmcp_secret"}' + ) + + with pytest.raises(StateFileError) as exc_info: + store.load() + + formatted_exception = "".join(traceback.format_exception(exc_info.value)) + assert "fmcp_secret" not in formatted_exception + assert exc_info.value.__cause__ is None + assert exc_info.value.__suppress_context__ is True diff --git a/tests/cli/deploy/test_horizon_client.py b/tests/cli/deploy/test_horizon_client.py new file mode 100644 index 000000000..1b7fa0b30 --- /dev/null +++ b/tests/cli/deploy/test_horizon_client.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from collections.abc import Callable +from urllib.parse import parse_qs + +import httpx2 +import pytest +from pydantic import SecretStr + +from fastmcp.cli.deploy.horizon_client import ( + DEVICE_AUTH_CLIENT_ID, + DEVICE_AUTH_GRANT_TYPE, + DeviceMetadata, + HorizonClient, + HorizonResponseError, + HorizonUnauthorizedError, + normalize_api_origin, +) + + +def mock_transport( + handler: Callable[[httpx2.Request], httpx2.Response], +) -> httpx2.MockTransport: + return httpx2.MockTransport(handler) + + +async def test_device_authorization_uses_the_oauth_form_contract() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.path == "/api/v0/oauth/device/authorization" + assert request.headers["content-type"].startswith( + "application/x-www-form-urlencoded" + ) + assert "authorization" not in request.headers + assert parse_qs(request.content.decode()) == { + "client_id": [DEVICE_AUTH_CLIENT_ID], + "device_name": ["Avery's laptop"], + "platform": ["darwin"], + "architecture": ["arm64"], + "client_version": ["4.0.0"], + } + return httpx2.Response( + 200, + json={ + "device_code": "device-secret", + "user_code": "BCDF-GHJK", + "verification_uri": "https://horizon.prefect.io/oauth/device", + "verification_uri_complete": "https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK", + "expires_in": 600, + "interval": 5, + }, + ) + + async with HorizonClient( + transport=mock_transport(handler), + ) as client: + result = await client.create_device_authorization( + DeviceMetadata( + device_name="Avery's laptop", + platform="darwin", + architecture="arm64", + client_version="4.0.0", + ) + ) + + assert result.user_code == "BCDF-GHJK" + assert result.interval == 5 + + +@pytest.mark.parametrize( + "error", + ["authorization_pending", "slow_down", "access_denied", "expired_token"], +) +async def test_device_token_exchange_returns_expected_poll_errors(error: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert parse_qs(request.content.decode()) == { + "grant_type": [DEVICE_AUTH_GRANT_TYPE], + "client_id": [DEVICE_AUTH_CLIENT_ID], + "device_code": ["device-secret"], + } + return httpx2.Response(400, json={"error": error}) + + async with HorizonClient(transport=mock_transport(handler)) as client: + result = await client.exchange_device_authorization("device-secret") + + assert result.error == error + assert result.access_token is None + + +@pytest.mark.parametrize("access_token", ["", " "]) +async def test_device_token_exchange_rejects_empty_access_tokens( + access_token: str, +) -> None: + async with HorizonClient( + transport=mock_transport( + lambda request: httpx2.Response( + 200, + json={"access_token": access_token, "token_type": "Bearer"}, + ) + ) + ) as client: + with pytest.raises(HorizonResponseError): + await client.exchange_device_authorization("device-secret") + + +async def test_device_token_exchange_keeps_the_api_key_secret() -> None: + async with HorizonClient( + transport=mock_transport( + lambda request: httpx2.Response( + 200, + json={"access_token": "fmcp_secret", "token_type": "Bearer"}, + ) + ) + ) as client: + result = await client.exchange_device_authorization("device-secret") + + assert isinstance(result.access_token, SecretStr) + assert result.access_token.get_secret_value() == "fmcp_secret" + assert "fmcp_secret" not in repr(result) + + +async def test_authenticated_routes_use_the_current_key_and_paginate() -> None: + cursors: list[str | None] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["authorization"] == "Bearer fmcp_secret" + if request.url.path == "/api/v0/me": + return httpx2.Response( + 200, + json={ + "user": { + "id": "user-id", + "email": "avery@example.com", + "name": "Avery", + "workosUserId": "workos-id", + "createdAt": "2026-08-08T00:00:00Z", + } + }, + ) + + assert request.url.path == "/api/v0/me/organizations" + cursor = request.url.params.get("cursor") + cursors.append(cursor) + if cursor is None: + return httpx2.Response( + 200, + json={ + "items": [{"id": "org-1", "name": "First", "slug": "first"}], + "meta": {"nextCursor": "next-page", "limit": 100}, + }, + ) + return httpx2.Response( + 200, + json={ + "items": [{"id": "org-2", "name": "Second", "slug": "second"}], + "meta": {"nextCursor": None, "limit": 100}, + }, + ) + + async with HorizonClient( + api_key="fmcp_secret", + transport=mock_transport(handler), + ) as client: + user = await client.get_current_user() + organizations = await client.list_organizations() + + assert user.email == "avery@example.com" + assert [organization.slug for organization in organizations] == ["first", "second"] + assert cursors == [None, "next-page"] + + +@pytest.mark.parametrize("count", [0, 1, 3]) +async def test_organization_memberships_preserve_zero_one_and_many(count: int) -> None: + organizations = [ + {"id": f"org-{index}", "name": f"Org {index}", "slug": f"org-{index}"} + for index in range(count) + ] + async with HorizonClient( + api_key="fmcp_secret", + transport=mock_transport( + lambda request: httpx2.Response( + 200, + json={ + "items": organizations, + "meta": {"nextCursor": None, "limit": 100}, + }, + ) + ), + ) as client: + result = await client.list_organizations() + + assert len(result) == count + + +async def test_revoke_uses_the_current_authenticated_key() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "DELETE" + assert request.url.path == "/api/v0/me/api-key" + assert request.headers["authorization"] == "Bearer fmcp_current" + return httpx2.Response(204) + + async with HorizonClient( + api_key="fmcp_current", + transport=mock_transport(handler), + ) as client: + await client.revoke_current_api_key() + + +async def test_protected_routes_require_a_credential() -> None: + async with HorizonClient( + transport=mock_transport(lambda request: httpx2.Response(200)) + ) as client: + with pytest.raises(HorizonUnauthorizedError): + await client.get_current_user() + + +async def test_invalid_responses_do_not_include_response_bodies() -> None: + secret_body = "fmcp_response_secret" + async with HorizonClient( + transport=mock_transport(lambda request: httpx2.Response(500, text=secret_body)) + ) as client: + with pytest.raises(HorizonResponseError) as exc_info: + await client.create_device_authorization() + + assert exc_info.value.status_code == 500 + assert secret_body not in str(exc_info.value) + + +@pytest.mark.parametrize( + "value", + [ + "ftp://horizon.prefect.io", + "https://user@example.com", + "https://horizon.prefect.io/path", + "https://horizon.prefect.io?query=value", + ], +) +def test_api_origin_rejects_values_that_are_not_origins(value: str) -> None: + with pytest.raises(ValueError): + normalize_api_origin(value) + + +def test_api_origin_normalizes_one_trailing_slash() -> None: + assert ( + normalize_api_origin("https://horizon.prefect.io/") + == "https://horizon.prefect.io" + ) From f856ce5e60b1cea4b2cae5313f10b2718e6bfc74 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 21:37:24 -0700 Subject: [PATCH 02/10] fix: apply Windows state ACLs to existing descriptors --- fastmcp_slim/fastmcp/cli/deploy/state.py | 13 +++++++------ tests/cli/deploy/test_credentials.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/fastmcp_slim/fastmcp/cli/deploy/state.py b/fastmcp_slim/fastmcp/cli/deploy/state.py index 3ee77efcc..c037ce450 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/state.py +++ b/fastmcp_slim/fastmcp/cli/deploy/state.py @@ -21,11 +21,15 @@ class StateFileError(RuntimeError): _WINDOWS_ACL_SCRIPT = r""" $ErrorActionPreference = "Stop" -$path = $args[0] +$path = $env:FASTMCP_STATE_PATH $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$acl = Get-Acl -LiteralPath $path +$acl.SetAccessRuleProtection($true, $false) +foreach ($existingRule in @($acl.Access)) { + $acl.RemoveAccessRuleSpecific($existingRule) +} if ([System.IO.Directory]::Exists($path)) { - $acl = [System.Security.AccessControl.DirectorySecurity]::new() $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit ` -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( @@ -36,7 +40,6 @@ if ([System.IO.Directory]::Exists($path)) { [System.Security.AccessControl.AccessControlType]::Allow ) } else { - $acl = [System.Security.AccessControl.FileSecurity]::new() $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( $sid, [System.Security.AccessControl.FileSystemRights]::FullControl, @@ -44,8 +47,6 @@ if ([System.IO.Directory]::Exists($path)) { ) } -$acl.SetOwner($sid) -$acl.SetAccessRuleProtection($true, $false) $acl.AddAccessRule($rule) Set-Acl -LiteralPath $path -AclObject $acl """ @@ -61,11 +62,11 @@ def _restrict_windows_access(path: Path) -> None: "-NonInteractive", "-Command", _WINDOWS_ACL_SCRIPT, - str(path), ], check=True, capture_output=True, text=True, + env={**os.environ, "FASTMCP_STATE_PATH": str(path)}, ) except (OSError, subprocess.SubprocessError) as exc: raise StateFileError("Could not restrict access to CLI state") from exc diff --git a/tests/cli/deploy/test_credentials.py b/tests/cli/deploy/test_credentials.py index bd6f8c514..a4ea55b45 100644 --- a/tests/cli/deploy/test_credentials.py +++ b/tests/cli/deploy/test_credentials.py @@ -5,6 +5,7 @@ import os import subprocess import traceback from pathlib import Path +from typing import cast import httpx2 import pytest @@ -162,9 +163,12 @@ def test_windows_acl_replaces_the_existing_access_list( path = tmp_path / "auth.json" path.write_text("{}") calls: list[list[str]] = [] + state_paths: list[str] = [] def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: calls.append(command) + environment = cast(dict[str, str], kwargs["env"]) + state_paths.append(environment["FASTMCP_STATE_PATH"]) return subprocess.CompletedProcess(command, 0, "", "") monkeypatch.setattr("fastmcp.cli.deploy.state.subprocess.run", run) @@ -178,11 +182,13 @@ def test_windows_acl_replaces_the_existing_access_list( "-NonInteractive", "-Command", calls[0][5], - str(path), ] ] - assert "FileSecurity]::new()" in calls[0][5] + assert state_paths == [str(path)] + assert "$path = $env:FASTMCP_STATE_PATH" in calls[0][5] + assert "Get-Acl -LiteralPath $path" in calls[0][5] assert "SetAccessRuleProtection($true, $false)" in calls[0][5] + assert "RemoveAccessRuleSpecific($existingRule)" in calls[0][5] @pytest.mark.skipif(os.name != "nt", reason="Windows ACL inspection") @@ -191,7 +197,7 @@ def test_windows_credential_state_allows_only_the_current_user(tmp_path: Path) - store = CredentialStore(state_directory) store.save("fmcp_secret") inspect_acl = r""" -$acl = Get-Acl -LiteralPath $args[0] +$acl = Get-Acl -LiteralPath $env:FASTMCP_STATE_PATH $current = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $access = @($acl.Access | ForEach-Object { $_.IdentityReference.Translate( @@ -215,11 +221,11 @@ $access = @($acl.Access | ForEach-Object { "-NonInteractive", "-Command", inspect_acl, - str(path), ], check=True, capture_output=True, text=True, + env={**os.environ, "FASTMCP_STATE_PATH": str(path)}, ) acl = json.loads(result.stdout) assert set(acl["access"]) == {acl["current"]} From 760bef1ab8913bba2b9291594a8405167d642a76 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 22:48:05 -0700 Subject: [PATCH 03/10] fix: distinguish public route authorization failures --- .../fastmcp/cli/deploy/horizon_client.py | 2 +- tests/cli/deploy/test_horizon_client.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py index 26b609e6d..f3f01553c 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py +++ b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py @@ -210,7 +210,7 @@ class HorizonClient: except httpx2.RequestError as exc: raise HorizonUnavailableError("The Horizon API is unavailable") from exc - if response.status_code == 401: + if authenticated and response.status_code == 401: raise HorizonUnauthorizedError("The Horizon credential is not valid") return response diff --git a/tests/cli/deploy/test_horizon_client.py b/tests/cli/deploy/test_horizon_client.py index 1b7fa0b30..a0f065400 100644 --- a/tests/cli/deploy/test_horizon_client.py +++ b/tests/cli/deploy/test_horizon_client.py @@ -213,6 +213,23 @@ async def test_protected_routes_require_a_credential() -> None: await client.get_current_user() +async def test_protected_routes_report_a_rejected_credential() -> None: + async with HorizonClient( + api_key="fmcp_invalid", + transport=mock_transport(lambda request: httpx2.Response(401)), + ) as client: + with pytest.raises(HorizonUnauthorizedError): + await client.get_current_user() + + +async def test_public_routes_do_not_report_a_missing_credential() -> None: + async with HorizonClient( + transport=mock_transport(lambda request: httpx2.Response(401)) + ) as client: + with pytest.raises(HorizonResponseError): + await client.create_device_authorization() + + async def test_invalid_responses_do_not_include_response_bodies() -> None: secret_body = "fmcp_response_secret" async with HorizonClient( From 86cbf38ed3157aa9668f3a392daeeaf97d908fb2 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:49:32 -0700 Subject: [PATCH 04/10] fix: harden Horizon state boundaries --- .../fastmcp/cli/deploy/configuration.py | 15 ++--- .../fastmcp/cli/deploy/credentials.py | 25 +++++++- .../fastmcp/cli/deploy/horizon_client.py | 4 ++ fastmcp_slim/fastmcp/cli/deploy/state.py | 61 ++++++++++++++++++- tests/cli/deploy/test_credentials.py | 51 ++++++++++++++++ tests/cli/deploy/test_horizon_client.py | 2 + 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/fastmcp_slim/fastmcp/cli/deploy/configuration.py b/fastmcp_slim/fastmcp/cli/deploy/configuration.py index 9f2f41ca1..750bb8e83 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/configuration.py +++ b/fastmcp_slim/fastmcp/cli/deploy/configuration.py @@ -12,7 +12,7 @@ from fastmcp.cli.deploy.horizon_client import ( DEFAULT_HORIZON_API_ORIGIN, normalize_api_origin, ) -from fastmcp.cli.deploy.state import read_state, write_state +from fastmcp.cli.deploy.state import read_state, state_lock, write_state class HorizonConfiguration(BaseModel): @@ -59,9 +59,10 @@ class ConfigurationStore: credentials: CredentialStore, ) -> HorizonConfiguration: """Set the origin and clear credentials before an origin change.""" - current = self.load() - updated = HorizonConfiguration(schemaVersion=1, apiOrigin=api_origin) - if updated.api_origin != current.api_origin: - credentials.clear() - self.save(updated) - return updated + with state_lock(self.path.parent): + current = self.load() + updated = HorizonConfiguration(schemaVersion=1, apiOrigin=api_origin) + if updated.api_origin != current.api_origin: + credentials.clear() + self.save(updated) + return updated diff --git a/fastmcp_slim/fastmcp/cli/deploy/credentials.py b/fastmcp_slim/fastmcp/cli/deploy/credentials.py index c70dbd3f8..bd129abee 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/credentials.py +++ b/fastmcp_slim/fastmcp/cli/deploy/credentials.py @@ -17,11 +17,12 @@ from pydantic import ( field_validator, ) -from fastmcp.cli.deploy.horizon_client import HorizonClient +from fastmcp.cli.deploy.horizon_client import HorizonClient, normalize_api_origin from fastmcp.cli.deploy.state import ( StateFileError, read_state, remove_state, + state_lock, write_state, ) @@ -79,6 +80,22 @@ class CredentialStore: }, ) + def save_for_origin( + self, + api_key: SecretStr | str, + *, + expected_api_origin: str, + ) -> None: + """Save a key only while its issuing Horizon origin is active.""" + from fastmcp.cli.deploy.configuration import ConfigurationStore + + expected_api_origin = normalize_api_origin(expected_api_origin) + with state_lock(self.path.parent): + active_api_origin = ConfigurationStore(self.path.parent).load().api_origin + if active_api_origin != expected_api_origin: + raise StateFileError("The Horizon host changed during login") + self.save(api_key) + def clear(self) -> None: remove_state(self.path) @@ -88,6 +105,7 @@ async def resolve_credential( *, environ: Mapping[str, str] | None = None, authorize: Callable[[], Awaitable[SecretStr]] | None = None, + expected_api_origin: str | None = None, ) -> ResolvedCredential: """Resolve environment, stored, then interactive credentials.""" environ = os.environ if environ is None else environ @@ -106,7 +124,10 @@ async def resolve_credential( raise AuthenticationRequiredError("Horizon authentication is required") api_key = await authorize() - store.save(api_key) + if expected_api_origin is None: + store.save(api_key) + else: + store.save_for_origin(api_key, expected_api_origin=expected_api_origin) return ResolvedCredential(api_key=api_key, source="interactive") diff --git a/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py index f3f01553c..26a66a10f 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py +++ b/fastmcp_slim/fastmcp/cli/deploy/horizon_client.py @@ -129,6 +129,10 @@ class DeviceTokenPoll: def normalize_api_origin(value: str) -> str: """Validate and normalize a Horizon API origin.""" parts = urlsplit(value) + try: + _ = parts.port + except ValueError: + raise ValueError("The Horizon API origin must be an HTTP origin") from None if ( parts.scheme not in {"http", "https"} or not parts.hostname diff --git a/fastmcp_slim/fastmcp/cli/deploy/state.py b/fastmcp_slim/fastmcp/cli/deploy/state.py index c037ce450..55cad4d74 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/state.py +++ b/fastmcp_slim/fastmcp/cli/deploy/state.py @@ -2,11 +2,13 @@ from __future__ import annotations +import errno import json import os import subprocess import tempfile -from contextlib import suppress +from collections.abc import Iterator +from contextlib import contextmanager, suppress from pathlib import Path from typing import Any, TypeVar @@ -90,6 +92,56 @@ def _prepare_directory(path: Path) -> None: _restrict_access(path, directory=True) +@contextmanager +def state_lock(directory: Path) -> Iterator[None]: + """Lock related CLI state changes across processes.""" + _prepare_directory(directory) + lock_path = directory / ".state.lock" + if lock_path.is_symlink(): + raise StateFileError("The CLI state lock must not be a symbolic link") + + lock_file = None + try: + lock_file = lock_path.open("a+b") + _restrict_access(lock_path) + if os.name == "nt": + import msvcrt + + if lock_path.stat().st_size == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + except (OSError, StateFileError) as exc: + if lock_file is not None: + with suppress(OSError): + lock_file.close() + if isinstance(exc, StateFileError): + raise + raise StateFileError("Could not lock CLI state") from exc + + try: + yield + finally: + if os.name == "nt": + import msvcrt + + with suppress(OSError): + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + with suppress(OSError): + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + with suppress(OSError): + lock_file.close() + + def read_state( path: Path, model: type[ModelT], @@ -145,7 +197,12 @@ def write_state(path: Path, data: dict[str, Any]) -> None: if os.name != "nt": directory_descriptor = os.open(path.parent, os.O_RDONLY) try: - os.fsync(directory_descriptor) + try: + os.fsync(directory_descriptor) + except OSError as exc: + unsupported = {errno.EINVAL, errno.ENOTSUP} + if exc.errno not in unsupported: + raise finally: os.close(directory_descriptor) except StateFileError: diff --git a/tests/cli/deploy/test_credentials.py b/tests/cli/deploy/test_credentials.py index a4ea55b45..23e98ca62 100644 --- a/tests/cli/deploy/test_credentials.py +++ b/tests/cli/deploy/test_credentials.py @@ -1,7 +1,9 @@ from __future__ import annotations +import errno import json import os +import stat import subprocess import traceback from pathlib import Path @@ -11,6 +13,10 @@ import httpx2 import pytest from pydantic import SecretStr +from fastmcp.cli.deploy.configuration import ( + ConfigurationStore, + HorizonConfiguration, +) from fastmcp.cli.deploy.credentials import ( AuthenticationRequiredError, CredentialStore, @@ -63,6 +69,26 @@ def test_credential_store_restricts_an_existing_secret_file(tmp_path: Path) -> N assert store.path.stat().st_mode & 0o777 == 0o600 +@pytest.mark.skipif(os.name == "nt", reason="POSIX directory fsync") +def test_atomic_write_ignores_unsupported_directory_fsync( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = CredentialStore(tmp_path) + original_fsync = os.fsync + + def fsync(descriptor: int) -> None: + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + raise OSError(errno.EINVAL, "directory sync is not supported") + original_fsync(descriptor) + + monkeypatch.setattr("fastmcp.cli.deploy.state.os.fsync", fsync) + + store.save("fmcp_secret") + + assert load_secret(store).get_secret_value() == "fmcp_secret" + + def test_atomic_write_preserves_previous_state_on_replace_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -132,6 +158,31 @@ async def test_interactive_credential_is_persisted(tmp_path: Path) -> None: assert load_secret(store).get_secret_value() == "fmcp_interactive" +async def test_interactive_credential_rejects_an_origin_change( + tmp_path: Path, +) -> None: + store = CredentialStore(tmp_path) + ConfigurationStore(tmp_path).save( + HorizonConfiguration( + schemaVersion=1, + apiOrigin="https://dev.horizon.prefect.io", + ) + ) + + async def authorize() -> SecretStr: + return SecretStr("fmcp_old_origin") + + with pytest.raises(StateFileError, match="host changed"): + await resolve_credential( + store, + environ={}, + authorize=authorize, + 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_horizon_client.py b/tests/cli/deploy/test_horizon_client.py index a0f065400..aa0e4dee0 100644 --- a/tests/cli/deploy/test_horizon_client.py +++ b/tests/cli/deploy/test_horizon_client.py @@ -249,6 +249,8 @@ async def test_invalid_responses_do_not_include_response_bodies() -> None: "https://user@example.com", "https://horizon.prefect.io/path", "https://horizon.prefect.io?query=value", + "https://horizon.prefect.io:abc", + "https://horizon.prefect.io:99999", ], ) def test_api_origin_rejects_values_that_are_not_origins(value: str) -> None: From 35ecc14234e9a20ffab77d59825988d4134c99b4 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 21:30:31 -0700 Subject: [PATCH 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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(