mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Add Prefect Horizon account commands (#4786)
* feat: add Horizon account commands * feat: add a Horizon host option to login * feat: refine Horizon account output * style: refine Horizon output headings * feat: describe device authorization requests * fix: preserve Horizon command contracts * fix: preserve environment credentials on logout * fix: keep Horizon state reads consistent * docs: hide the Horizon host override
This commit is contained in:
parent
c9cf23779e
commit
0d86007617
10 changed files with 1462 additions and 0 deletions
|
|
@ -28,6 +28,9 @@ fastmcp --help
|
|||
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
|
||||
| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
|
||||
| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
|
||||
| `login` | Sign in to Prefect Horizon with a browser device flow |
|
||||
| `whoami` | Show the current Horizon account |
|
||||
| `logout` | Revoke the current Horizon key and remove the local credential |
|
||||
| `version` | Print version info (`--copy` to copy to clipboard) |
|
||||
|
||||
## Server Targets
|
||||
|
|
@ -81,6 +84,38 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
|
|||
|
||||
## Authentication
|
||||
|
||||
### Prefect Horizon Account
|
||||
|
||||
Use the top-level account commands to manage the credential for Prefect Horizon.
|
||||
|
||||
```bash
|
||||
fastmcp login
|
||||
fastmcp whoami
|
||||
fastmcp logout
|
||||
```
|
||||
|
||||
`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
|
||||
When login needs a new key, it shows a verification URL and code.
|
||||
It opens a browser when the terminal supports it.
|
||||
If the browser does not open, use the shown URL and code on another device.
|
||||
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
|
||||
|
||||
Login stores only the personal Horizon API key.
|
||||
It does not select or store a deployment organization.
|
||||
`fastmcp whoami` gets the current user from Horizon.
|
||||
`fastmcp logout` attempts to revoke the stored key and always removes its local credential.
|
||||
|
||||
Set `HORIZON_API_KEY` to use an environment credential instead.
|
||||
The CLI gives that value first precedence and never stores it.
|
||||
When this variable controls the session, logout does not revoke or remove any credential.
|
||||
Remove the variable from your environment to sign out.
|
||||
|
||||
Use `--json` for stable command results.
|
||||
During JSON login, the verification challenge goes to stderr and the final result goes to stdout.
|
||||
JSON mode does not open a browser or ask a question.
|
||||
|
||||
### MCP Server Authentication
|
||||
|
||||
When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
|
||||
|
||||
To skip authentication entirely — useful for local development servers — pass `--auth none`:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,39 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste
|
|||
Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users.
|
||||
</Info>
|
||||
|
||||
## FastMCP CLI Account
|
||||
|
||||
Sign in to Horizon from the FastMCP CLI with the device authorization flow.
|
||||
|
||||
```bash
|
||||
fastmcp login
|
||||
```
|
||||
|
||||
The command uses an environment key or a valid stored key when one is available.
|
||||
When login needs a new key, it shows a verification URL and code before it opens the browser.
|
||||
If the browser cannot open, visit the shown URL and enter the code.
|
||||
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
|
||||
New users can register and create their first Horizon organization in the browser.
|
||||
|
||||
Check the active account after login.
|
||||
|
||||
```bash
|
||||
fastmcp whoami
|
||||
```
|
||||
|
||||
Remove the local credential and revoke the active personal API key when possible.
|
||||
|
||||
```bash
|
||||
fastmcp logout
|
||||
```
|
||||
|
||||
Login does not select or store a deployment organization.
|
||||
|
||||
For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key.
|
||||
The CLI never writes the environment value to its credential file.
|
||||
When this variable controls the session, logout does not revoke or remove any credential.
|
||||
Remove the variable from the environment to sign out.
|
||||
|
||||
## The Platform
|
||||
|
||||
Horizon is organized into four integrated pillars:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal file
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""Public Prefect Horizon authentication commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from cyclopts import Parameter
|
||||
from pydantic import SecretStr
|
||||
from rich.status import Status
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.cli.deploy.authentication import (
|
||||
DeviceAuthorizationDeniedError,
|
||||
DeviceAuthorizationError,
|
||||
DeviceAuthorizationExpiredError,
|
||||
authorize_device,
|
||||
)
|
||||
from fastmcp.cli.deploy.configuration import (
|
||||
ConfigurationStore,
|
||||
HorizonConfiguration,
|
||||
)
|
||||
from fastmcp.cli.deploy.credentials import (
|
||||
AuthenticationRequiredError,
|
||||
CredentialStore,
|
||||
ResolvedCredential,
|
||||
)
|
||||
from fastmcp.cli.deploy.horizon_client import (
|
||||
DeviceAuthorization,
|
||||
DeviceMetadata,
|
||||
HorizonClient,
|
||||
HorizonResponseError,
|
||||
HorizonUnauthorizedError,
|
||||
HorizonUnavailableError,
|
||||
HorizonUser,
|
||||
)
|
||||
from fastmcp.cli.deploy.output import (
|
||||
CommandName,
|
||||
ErrorCategory,
|
||||
emit_device_challenge,
|
||||
emit_environment_logout,
|
||||
emit_error,
|
||||
emit_identity,
|
||||
emit_logout,
|
||||
start_device_approval_status,
|
||||
stop_device_approval_status,
|
||||
)
|
||||
from fastmcp.cli.deploy.state import StateFileError, state_lock
|
||||
|
||||
JsonOption = Annotated[
|
||||
bool,
|
||||
Parameter(
|
||||
name="--json",
|
||||
help="Write one final JSON result to stdout",
|
||||
negative=(),
|
||||
),
|
||||
]
|
||||
HostOption = Annotated[
|
||||
str | None,
|
||||
Parameter(
|
||||
name="--host",
|
||||
help="Use and save a different Horizon host URL",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _can_open_browser() -> bool:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
|
||||
def _device_metadata() -> DeviceMetadata:
|
||||
return DeviceMetadata(
|
||||
device_name=platform.node() or None,
|
||||
platform=platform.system().lower() or None,
|
||||
architecture=platform.machine().lower() or None,
|
||||
client_version=fastmcp.__version__,
|
||||
)
|
||||
|
||||
|
||||
def _load_session_snapshot(
|
||||
credentials: CredentialStore,
|
||||
) -> tuple[HorizonConfiguration, ResolvedCredential | None]:
|
||||
with state_lock(credentials.path.parent):
|
||||
configuration = ConfigurationStore(credentials.path.parent).load()
|
||||
environment_key = os.environ.get("HORIZON_API_KEY")
|
||||
if environment_key:
|
||||
credential = ResolvedCredential(
|
||||
api_key=SecretStr(environment_key),
|
||||
source="environment",
|
||||
)
|
||||
else:
|
||||
stored_key = credentials.load()
|
||||
credential = (
|
||||
ResolvedCredential(api_key=stored_key, source="stored")
|
||||
if stored_key is not None
|
||||
else None
|
||||
)
|
||||
return configuration, credential
|
||||
|
||||
|
||||
def _fail(
|
||||
command: CommandName,
|
||||
category: ErrorCategory,
|
||||
message: str,
|
||||
*,
|
||||
json_output: bool,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> NoReturn:
|
||||
emit_error(
|
||||
command,
|
||||
category,
|
||||
message,
|
||||
json_output=json_output,
|
||||
details=details,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _fail_for_expected_error(
|
||||
command: CommandName,
|
||||
error: Exception,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> NoReturn:
|
||||
if isinstance(error, AuthenticationRequiredError):
|
||||
_fail(
|
||||
command,
|
||||
"authentication_required",
|
||||
"Run `fastmcp login` to sign in to Prefect Horizon.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonUnauthorizedError):
|
||||
_fail(
|
||||
command,
|
||||
"authentication_invalid",
|
||||
"The Horizon credential is not valid. Run `fastmcp login` again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationDeniedError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_denied",
|
||||
"The device authorization request was denied.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationExpiredError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_expired",
|
||||
"The device authorization request expired. Run the command again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_failed",
|
||||
"The device authorization request failed. Run the command again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonUnavailableError):
|
||||
_fail(
|
||||
command,
|
||||
"horizon_unavailable",
|
||||
"The Horizon API is unavailable. Try again later.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonResponseError):
|
||||
_fail(
|
||||
command,
|
||||
"horizon_error",
|
||||
"Horizon returned an unexpected response. Try again later.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, StateFileError):
|
||||
_fail(
|
||||
command,
|
||||
"state_error",
|
||||
"The local Horizon state is invalid.",
|
||||
json_output=json_output,
|
||||
)
|
||||
raise error
|
||||
|
||||
|
||||
async def _get_user(
|
||||
api_origin: str,
|
||||
credential: ResolvedCredential,
|
||||
) -> HorizonUser:
|
||||
async with HorizonClient(api_origin, api_key=credential.api_key) as client:
|
||||
return await client.get_current_user()
|
||||
|
||||
|
||||
async def login(
|
||||
*,
|
||||
host: HostOption = None,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Sign in to Prefect Horizon."""
|
||||
credentials = CredentialStore()
|
||||
|
||||
try:
|
||||
configuration_store = ConfigurationStore()
|
||||
requested_configuration: HorizonConfiguration | None = None
|
||||
if host is not None:
|
||||
try:
|
||||
requested_configuration = configuration_store.set_api_origin(
|
||||
host,
|
||||
credentials=credentials,
|
||||
)
|
||||
except ValueError:
|
||||
_fail(
|
||||
"login",
|
||||
"invalid_host",
|
||||
"The Horizon host must be an HTTP origin.",
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if (
|
||||
requested_configuration is not None
|
||||
and configuration.api_origin != requested_configuration.api_origin
|
||||
):
|
||||
raise StateFileError("The Horizon host changed during login")
|
||||
|
||||
async def device_authorization():
|
||||
approval_status: Status | None = None
|
||||
|
||||
def show_challenge(challenge: DeviceAuthorization) -> None:
|
||||
nonlocal approval_status
|
||||
emit_device_challenge(challenge, json_output=json_output)
|
||||
approval_status = start_device_approval_status(json_output=json_output)
|
||||
|
||||
try:
|
||||
async with HorizonClient(configuration.api_origin) as client:
|
||||
return await authorize_device(
|
||||
client,
|
||||
metadata=_device_metadata(),
|
||||
on_challenge=show_challenge,
|
||||
open_browser=not json_output and _can_open_browser(),
|
||||
browser_opener=webbrowser.open,
|
||||
)
|
||||
finally:
|
||||
stop_device_approval_status(approval_status)
|
||||
|
||||
async def interactive_credential() -> ResolvedCredential:
|
||||
api_key = await device_authorization()
|
||||
credentials.save_for_origin(
|
||||
api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
return ResolvedCredential(api_key=api_key, source="interactive")
|
||||
|
||||
if credential is None:
|
||||
credential = await interactive_credential()
|
||||
|
||||
try:
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
if credential.source == "environment":
|
||||
raise
|
||||
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
if credential.source == "interactive":
|
||||
raise
|
||||
|
||||
credential = await interactive_credential()
|
||||
try:
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
raise
|
||||
except (
|
||||
AuthenticationRequiredError,
|
||||
DeviceAuthorizationError,
|
||||
HorizonResponseError,
|
||||
HorizonUnauthorizedError,
|
||||
HorizonUnavailableError,
|
||||
StateFileError,
|
||||
) as error:
|
||||
_fail_for_expected_error("login", error, json_output=json_output)
|
||||
|
||||
emit_identity(
|
||||
"login",
|
||||
user,
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
|
||||
async def whoami(
|
||||
*,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Show the current Prefect Horizon user."""
|
||||
credentials = CredentialStore()
|
||||
configuration: HorizonConfiguration | None = None
|
||||
credential: ResolvedCredential | None = None
|
||||
|
||||
try:
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if credential is None:
|
||||
raise AuthenticationRequiredError("Horizon authentication is required")
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError as error:
|
||||
if (
|
||||
configuration is not None
|
||||
and credential is not None
|
||||
and credential.source == "stored"
|
||||
):
|
||||
try:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
except StateFileError as cleanup_error:
|
||||
_fail_for_expected_error(
|
||||
"whoami",
|
||||
cleanup_error,
|
||||
json_output=json_output,
|
||||
)
|
||||
_fail_for_expected_error("whoami", error, json_output=json_output)
|
||||
except (
|
||||
AuthenticationRequiredError,
|
||||
HorizonResponseError,
|
||||
HorizonUnavailableError,
|
||||
StateFileError,
|
||||
) as error:
|
||||
_fail_for_expected_error("whoami", error, json_output=json_output)
|
||||
|
||||
emit_identity(
|
||||
"whoami",
|
||||
user,
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
|
||||
async def logout(
|
||||
*,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Revoke the current Horizon key and remove the local credential."""
|
||||
credentials = CredentialStore()
|
||||
|
||||
if os.environ.get("HORIZON_API_KEY"):
|
||||
emit_environment_logout(json_output=json_output)
|
||||
return
|
||||
|
||||
try:
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if credential is None:
|
||||
emit_logout(remote_revoked=False, json_output=json_output)
|
||||
return
|
||||
|
||||
async with HorizonClient(
|
||||
configuration.api_origin,
|
||||
api_key=credential.api_key,
|
||||
) as client:
|
||||
try:
|
||||
await client.revoke_current_api_key()
|
||||
finally:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
emit_logout(remote_revoked=False, json_output=json_output)
|
||||
return
|
||||
except (HorizonResponseError, HorizonUnavailableError):
|
||||
_fail(
|
||||
"logout",
|
||||
"remote_revocation_failed",
|
||||
"The local credential was removed, but the remote key can remain active.",
|
||||
json_output=json_output,
|
||||
details={
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
},
|
||||
)
|
||||
except StateFileError as error:
|
||||
_fail_for_expected_error("logout", error, json_output=json_output)
|
||||
|
||||
emit_logout(remote_revoked=True, json_output=json_output)
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -96,6 +97,32 @@ class CredentialStore:
|
|||
raise StateFileError("The Horizon host changed during login")
|
||||
self.save(api_key)
|
||||
|
||||
def clear_if_matches(
|
||||
self,
|
||||
api_key: SecretStr | str,
|
||||
*,
|
||||
expected_api_origin: str,
|
||||
) -> None:
|
||||
"""Clear a key only while its Horizon origin and value are active."""
|
||||
from fastmcp.cli.deploy.configuration import ConfigurationStore
|
||||
|
||||
expected_api_origin = normalize_api_origin(expected_api_origin)
|
||||
expected_api_key = (
|
||||
api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
||||
)
|
||||
with state_lock(self.path.parent):
|
||||
active_api_origin = ConfigurationStore(self.path.parent).load().api_origin
|
||||
active_api_key = self.load()
|
||||
if (
|
||||
active_api_origin == expected_api_origin
|
||||
and active_api_key is not None
|
||||
and secrets.compare_digest(
|
||||
active_api_key.get_secret_value(),
|
||||
expected_api_key,
|
||||
)
|
||||
):
|
||||
self.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
remove_state(self.path)
|
||||
|
||||
|
|
|
|||
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal file
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""Stable terminal and JSON output for Horizon CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
from rich import box
|
||||
from rich.align import Align
|
||||
from rich.console import Console, Group
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.status import Status
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
|
||||
|
||||
CommandName = Literal["login", "logout", "whoami"]
|
||||
ErrorCategory = Literal[
|
||||
"authentication_invalid",
|
||||
"authentication_required",
|
||||
"authorization_denied",
|
||||
"authorization_expired",
|
||||
"authorization_failed",
|
||||
"horizon_error",
|
||||
"horizon_unavailable",
|
||||
"invalid_host",
|
||||
"remote_revocation_failed",
|
||||
"state_error",
|
||||
]
|
||||
|
||||
console = Console()
|
||||
error_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _write_json(payload: object, *, stderr: bool = False) -> None:
|
||||
stream = sys.stderr if stderr else sys.stdout
|
||||
print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True)
|
||||
|
||||
|
||||
def _banner(title: str, *, style: str) -> Panel:
|
||||
return Panel(
|
||||
Align.center(Text(title, style=f"bold {style}")),
|
||||
box=box.ROUNDED,
|
||||
border_style=style,
|
||||
padding=(0, 1),
|
||||
width=52,
|
||||
)
|
||||
|
||||
|
||||
def _account_panel(
|
||||
user: HorizonUser,
|
||||
*,
|
||||
title: str,
|
||||
message: str,
|
||||
) -> Panel:
|
||||
name = Text(user.name or user.email, style="bold")
|
||||
details: list[Text] = [name]
|
||||
if user.name:
|
||||
details.append(Text(user.email, style="cyan"))
|
||||
details.extend([Text(), Text(message, style="green")])
|
||||
return Panel(
|
||||
Group(*details),
|
||||
title=Text(title, style="bold green"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="green",
|
||||
padding=(1, 2),
|
||||
width=52,
|
||||
)
|
||||
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
if seconds % 60 == 0:
|
||||
minutes = seconds // 60
|
||||
unit = "minute" if minutes == 1 else "minutes"
|
||||
return f"{minutes} {unit}"
|
||||
unit = "second" if seconds == 1 else "seconds"
|
||||
return f"{seconds} {unit}"
|
||||
|
||||
|
||||
def emit_device_challenge(
|
||||
authorization: DeviceAuthorization,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show a device challenge before polling starts."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": authorization.verification_uri,
|
||||
"verificationUrlComplete": authorization.verification_uri_complete,
|
||||
"userCode": authorization.user_code,
|
||||
},
|
||||
stderr=True,
|
||||
)
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print(_banner("Deploy FastMCP on Horizon", style="magenta"))
|
||||
console.print()
|
||||
console.print(Text("✓ Device authorization started", style="bold green"))
|
||||
console.print()
|
||||
console.print(" Open this URL in your browser:")
|
||||
console.print()
|
||||
console.print(
|
||||
Padding(
|
||||
Text(authorization.verification_uri_complete, style="cyan underline"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
console.print(" Confirm this code:")
|
||||
console.print()
|
||||
code = Table.grid()
|
||||
code.add_column(justify="center", width=52)
|
||||
code.add_row(Text(authorization.user_code, style="bold"))
|
||||
console.print(code)
|
||||
console.print()
|
||||
expires_in = _format_duration(authorization.expires_in)
|
||||
console.print(Text(f"The request expires in {expires_in}.", style="dim"))
|
||||
console.print(Text("Press Ctrl-C to cancel.", style="dim"))
|
||||
console.print()
|
||||
|
||||
|
||||
def start_device_approval_status(*, json_output: bool) -> Status | None:
|
||||
"""Start the terminal spinner while the browser approval is pending."""
|
||||
if json_output:
|
||||
return None
|
||||
status = console.status(
|
||||
"[cyan]Waiting for approval in your browser[/cyan]",
|
||||
spinner="dots",
|
||||
spinner_style="cyan",
|
||||
)
|
||||
status.start()
|
||||
return status
|
||||
|
||||
|
||||
def stop_device_approval_status(status: Status | None) -> None:
|
||||
"""Stop a device approval spinner when one is active."""
|
||||
if status is not None:
|
||||
status.stop()
|
||||
|
||||
|
||||
def emit_identity(
|
||||
command: Literal["login", "whoami"],
|
||||
user: HorizonUser,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show the authenticated user."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": command,
|
||||
"user": user.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
console.print()
|
||||
if command == "login":
|
||||
panel = _account_panel(
|
||||
user,
|
||||
title="Logged into Horizon",
|
||||
message="You are signed in to FastMCP.",
|
||||
)
|
||||
else:
|
||||
panel = _account_panel(
|
||||
user,
|
||||
title="Horizon Account",
|
||||
message="● Signed in",
|
||||
)
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_environment_logout(*, json_output: bool) -> None:
|
||||
"""Explain why logout cannot change an environment credential."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
message = Group(
|
||||
Text("This session uses HORIZON_API_KEY.", style="bold"),
|
||||
Text("Remove it from your environment to sign out."),
|
||||
Text("No credential was revoked or removed.", style="dim"),
|
||||
)
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
message,
|
||||
title=Text("Horizon Account", style="bold cyan"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_logout(
|
||||
*,
|
||||
remote_revoked: bool,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show a successful local logout result."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": remote_revoked,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if remote_revoked:
|
||||
title = "Logged out of Horizon"
|
||||
message = "The Horizon credential was revoked and removed from this device."
|
||||
style = "green"
|
||||
else:
|
||||
title = "Horizon Account"
|
||||
message = "No active Horizon credential remains on this device."
|
||||
style = "cyan"
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
Text(message),
|
||||
title=Text(title, style=f"bold {style}"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style=style,
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_error(
|
||||
command: CommandName,
|
||||
category: ErrorCategory,
|
||||
message: str,
|
||||
*,
|
||||
json_output: bool,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
"""Show a stable expected command failure."""
|
||||
if json_output:
|
||||
payload: dict[str, object] = {
|
||||
"ok": False,
|
||||
"command": command,
|
||||
"error": {
|
||||
"category": category,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if details:
|
||||
payload.update(details)
|
||||
_write_json(payload)
|
||||
return
|
||||
|
||||
titles = {
|
||||
"login": "✗ Sign in failed",
|
||||
"logout": "✗ Sign out failed",
|
||||
"whoami": "✗ Account lookup failed",
|
||||
}
|
||||
error_console.print()
|
||||
error_console.print(
|
||||
Panel(
|
||||
Text(message),
|
||||
title=Text(titles[command], style="bold red"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
error_console.print()
|
||||
462
tests/cli/deploy/test_command.py
Normal file
462
tests/cli/deploy/test_command.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
import fastmcp
|
||||
import fastmcp.cli.deploy.authentication as authentication_module
|
||||
import fastmcp.cli.deploy.command as command_module
|
||||
from fastmcp.cli.deploy.command import login, logout, whoami
|
||||
from fastmcp.cli.deploy.configuration import ConfigurationStore
|
||||
from fastmcp.cli.deploy.credentials import CredentialStore
|
||||
from fastmcp.cli.deploy.horizon_client import HorizonClient
|
||||
from fastmcp.cli.deploy.state import StateFileError
|
||||
|
||||
|
||||
class HorizonAuthAPI:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token_error: str | None = None,
|
||||
revoke_status: int = 204,
|
||||
invalid_api_key: str | None = None,
|
||||
on_request: Callable[[httpx2.Request], None] | None = None,
|
||||
) -> None:
|
||||
self.token_error = token_error
|
||||
self.revoke_status = revoke_status
|
||||
self.invalid_api_key = invalid_api_key
|
||||
self.on_request = on_request
|
||||
self.requests: list[httpx2.Request] = []
|
||||
|
||||
def __call__(self, request: httpx2.Request) -> httpx2.Response:
|
||||
self.requests.append(request)
|
||||
if self.on_request is not None:
|
||||
self.on_request(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v0/oauth/device/authorization":
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device-secret",
|
||||
"user_code": "ABCD-EFGH",
|
||||
"verification_uri": "https://horizon.prefect.io/oauth/device",
|
||||
"verification_uri_complete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"expires_in": 600,
|
||||
"interval": 1,
|
||||
},
|
||||
)
|
||||
if path == "/api/v0/oauth/device/token":
|
||||
if self.token_error is not None:
|
||||
return httpx2.Response(400, json={"error": self.token_error})
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
|
||||
)
|
||||
if path == "/api/v0/me":
|
||||
if request.headers.get("Authorization") == (
|
||||
f"Bearer {self.invalid_api_key}"
|
||||
):
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
}
|
||||
},
|
||||
)
|
||||
if path == "/api/v0/me/api-key":
|
||||
return httpx2.Response(self.revoke_status)
|
||||
raise AssertionError(f"Unexpected request: {request.method} {path}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_horizon_api(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Callable[[HorizonAuthAPI], None]:
|
||||
def use(api: HorizonAuthAPI) -> None:
|
||||
transport = httpx2.MockTransport(api)
|
||||
|
||||
def client(
|
||||
api_origin: str,
|
||||
*,
|
||||
api_key: SecretStr | str | None = None,
|
||||
) -> HorizonClient:
|
||||
return HorizonClient(
|
||||
api_origin,
|
||||
api_key=api_key,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(command_module, "HorizonClient", client)
|
||||
|
||||
return use
|
||||
|
||||
|
||||
def test_session_snapshot_reads_host_and_credential_under_one_lock(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
configuration_load = ConfigurationStore.load
|
||||
credential_load = CredentialStore.load
|
||||
|
||||
@contextmanager
|
||||
def lock(directory: Path) -> Iterator[None]:
|
||||
events.append("lock")
|
||||
yield
|
||||
events.append("unlock")
|
||||
|
||||
def load_configuration(store: ConfigurationStore):
|
||||
events.append("configuration")
|
||||
return configuration_load(store)
|
||||
|
||||
def load_credential(store: CredentialStore):
|
||||
events.append("credential")
|
||||
return credential_load(store)
|
||||
|
||||
monkeypatch.setattr(command_module, "state_lock", lock)
|
||||
monkeypatch.setattr(ConfigurationStore, "load", load_configuration)
|
||||
monkeypatch.setattr(CredentialStore, "load", load_credential)
|
||||
|
||||
configuration, credential = command_module._load_session_snapshot(CredentialStore())
|
||||
|
||||
assert configuration.api_origin == "https://horizon.prefect.io"
|
||||
assert credential is None
|
||||
assert events == ["lock", "configuration", "credential", "unlock"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
|
||||
|
||||
|
||||
async def test_json_login_writes_one_result_and_challenge_to_stderr(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
browser_open = Mock()
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
|
||||
monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
|
||||
monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
stdout_lines = captured.out.strip().splitlines()
|
||||
assert len(stdout_lines) == 1
|
||||
assert json.loads(stdout_lines[0]) == {
|
||||
"ok": True,
|
||||
"command": "login",
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
},
|
||||
}
|
||||
assert json.loads(captured.err) == {
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": "https://horizon.prefect.io/oauth/device",
|
||||
"verificationUrlComplete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"userCode": "ABCD-EFGH",
|
||||
}
|
||||
browser_open.assert_not_called()
|
||||
authorization_request = next(
|
||||
request
|
||||
for request in api.requests
|
||||
if request.url.path == "/api/v0/oauth/device/authorization"
|
||||
)
|
||||
assert parse_qs(authorization_request.content.decode()) == {
|
||||
"client_id": ["fastmcp-cli"],
|
||||
"device_name": ["Avery's laptop"],
|
||||
"platform": ["darwin"],
|
||||
"architecture": ["arm64"],
|
||||
"client_version": ["4.0.0"],
|
||||
}
|
||||
|
||||
state = json.loads(CredentialStore().path.read_text())
|
||||
assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
|
||||
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
|
||||
|
||||
|
||||
async def test_login_host_is_saved_before_device_authorization(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
|
||||
await login(host="https://dev.horizon.prefect.io/", json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out)["ok"] is True
|
||||
configuration_path = fastmcp.settings.home / "cli" / "config.json"
|
||||
assert json.loads(configuration_path.read_text()) == {
|
||||
"schemaVersion": 1,
|
||||
"apiOrigin": "https://dev.horizon.prefect.io",
|
||||
}
|
||||
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
|
||||
|
||||
|
||||
async def test_login_rejects_an_invalid_host(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await login(host="https://horizon.prefect.io/path", json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "invalid_host"
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
||||
|
||||
async def test_tty_login_survives_browser_open_failure(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI())
|
||||
browser_open = Mock(side_effect=OSError("No browser"))
|
||||
monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
|
||||
await login()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "https://horizon.prefect.io/oauth/device" in output
|
||||
assert "ABCD-EFGH" in output
|
||||
assert "Logged into Horizon" in output
|
||||
assert "Ada" in output
|
||||
assert "ada@example.com" in output
|
||||
assert "Organization" not in output
|
||||
browser_open.assert_called_once()
|
||||
|
||||
|
||||
async def test_whoami_uses_the_stored_key_after_a_restart(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
await login(json_output=True)
|
||||
capsys.readouterr()
|
||||
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["command"] == "whoami"
|
||||
assert result["user"]["email"] == "ada@example.com"
|
||||
assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
|
||||
|
||||
|
||||
async def test_login_replaces_an_invalid_stored_key(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
|
||||
CredentialStore().save("fmcp_stale_key")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out)["ok"] is True
|
||||
assert json.loads(captured.err)["event"] == "device_authorization"
|
||||
stored_key = CredentialStore().load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_device_key"
|
||||
|
||||
|
||||
async def test_login_never_persists_an_environment_key(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
assert CredentialStore().path.exists() is False
|
||||
assert not any(
|
||||
request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
|
||||
)
|
||||
|
||||
|
||||
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
|
||||
CredentialStore().save("fmcp_stale_key")
|
||||
|
||||
def fail_clear(store: CredentialStore) -> None:
|
||||
raise StateFileError("cleanup failed")
|
||||
|
||||
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "state_error"
|
||||
|
||||
|
||||
async def test_whoami_does_not_clear_a_newer_host_credential(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
credentials = CredentialStore()
|
||||
credentials.save("fmcp_stale_key")
|
||||
switched = False
|
||||
|
||||
def switch_host(request: httpx2.Request) -> None:
|
||||
nonlocal switched
|
||||
if request.url.path != "/api/v0/me" or switched:
|
||||
return
|
||||
switched = True
|
||||
ConfigurationStore().set_api_origin(
|
||||
"https://dev.horizon.prefect.io",
|
||||
credentials=credentials,
|
||||
)
|
||||
credentials.save_for_origin(
|
||||
"fmcp_new_key",
|
||||
expected_api_origin="https://dev.horizon.prefect.io",
|
||||
)
|
||||
|
||||
api = HorizonAuthAPI(
|
||||
invalid_api_key="fmcp_stale_key",
|
||||
on_request=switch_host,
|
||||
)
|
||||
use_horizon_api(api)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "authentication_invalid"
|
||||
assert ConfigurationStore().load().api_origin == ("https://dev.horizon.prefect.io")
|
||||
stored_key = credentials.load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_new_key"
|
||||
assert api.requests[0].url.host == "horizon.prefect.io"
|
||||
|
||||
|
||||
async def test_json_whoami_does_not_start_device_authorization(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
browser_open = Mock()
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "authentication_required"
|
||||
browser_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("token_error", "category"),
|
||||
[
|
||||
("access_denied", "authorization_denied"),
|
||||
("expired_token", "authorization_expired"),
|
||||
],
|
||||
)
|
||||
async def test_json_login_reports_stable_device_failures(
|
||||
token_error: str,
|
||||
category: str,
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(token_error=token_error))
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await login(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == category
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
||||
|
||||
async def test_logout_does_not_modify_environment_or_stored_credentials(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
await logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
stored_key = CredentialStore().load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_stored_key"
|
||||
assert api.requests == []
|
||||
|
||||
|
||||
async def test_logout_revokes_the_remote_key_and_clears_local_state(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
await logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": True,
|
||||
}
|
||||
assert CredentialStore().path.exists() is False
|
||||
assert any(
|
||||
request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
|
||||
for request in api.requests
|
||||
)
|
||||
|
||||
|
||||
async def test_logout_clears_local_state_when_remote_revocation_fails(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(revoke_status=503))
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await logout(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "remote_revocation_failed"
|
||||
assert result["localCredentialRemoved"] is True
|
||||
assert result["remoteCredentialMayRemain"] is True
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
|
@ -183,6 +183,29 @@ async def test_interactive_credential_rejects_an_origin_change(
|
|||
assert store.load() is None
|
||||
|
||||
|
||||
def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_current")
|
||||
|
||||
store.clear_if_matches(
|
||||
"fmcp_different",
|
||||
expected_api_origin="https://horizon.prefect.io",
|
||||
)
|
||||
store.clear_if_matches(
|
||||
"fmcp_current",
|
||||
expected_api_origin="https://dev.horizon.prefect.io",
|
||||
)
|
||||
|
||||
assert load_secret(store).get_secret_value() == "fmcp_current"
|
||||
|
||||
store.clear_if_matches(
|
||||
"fmcp_current",
|
||||
expected_api_origin="https://horizon.prefect.io",
|
||||
)
|
||||
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
|
||||
with pytest.raises(AuthenticationRequiredError):
|
||||
await resolve_credential(CredentialStore(tmp_path), environ={})
|
||||
|
|
|
|||
167
tests/cli/deploy/test_output.py
Normal file
167
tests/cli/deploy/test_output.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
|
||||
from fastmcp.cli.deploy.output import (
|
||||
emit_device_challenge,
|
||||
emit_environment_logout,
|
||||
emit_error,
|
||||
emit_identity,
|
||||
emit_logout,
|
||||
)
|
||||
|
||||
|
||||
def authorization() -> DeviceAuthorization:
|
||||
return DeviceAuthorization(
|
||||
device_code="device-secret",
|
||||
user_code="ABCD-EFGH",
|
||||
verification_uri="https://horizon.prefect.io/oauth/device",
|
||||
verification_uri_complete=(
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
expires_in=600,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
|
||||
def user() -> HorizonUser:
|
||||
return HorizonUser(id="user-1", email="ada@example.com", name="Ada")
|
||||
|
||||
|
||||
def test_json_device_challenge_uses_only_stderr(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_device_challenge(authorization(), json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert json.loads(captured.err) == {
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": "https://horizon.prefect.io/oauth/device",
|
||||
"verificationUrlComplete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"userCode": "ABCD-EFGH",
|
||||
}
|
||||
|
||||
|
||||
def test_tty_device_challenge_uses_the_sign_in_layout(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_device_challenge(authorization(), json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "│" in output
|
||||
assert "Deploy FastMCP on Horizon" in output
|
||||
assert "✓ Device authorization started" in output
|
||||
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
|
||||
assert "ABCD-EFGH" in output
|
||||
assert "The request expires in 10 minutes." in output
|
||||
|
||||
|
||||
def test_json_identity_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_identity("login", user(), json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"command": "login",
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_tty_identity_uses_an_account_panel(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_identity("whoami", user(), json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "│" in output
|
||||
assert "Horizon Account" in output
|
||||
assert "Ada" in output
|
||||
assert "ada@example.com" in output
|
||||
assert "● Signed in" in output
|
||||
assert "Organization" not in output
|
||||
|
||||
|
||||
def test_json_error_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_error(
|
||||
"logout",
|
||||
"remote_revocation_failed",
|
||||
"The remote key can remain active.",
|
||||
json_output=True,
|
||||
details={
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
},
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result == {
|
||||
"ok": False,
|
||||
"command": "logout",
|
||||
"error": {
|
||||
"category": "remote_revocation_failed",
|
||||
"message": "The remote key can remain active.",
|
||||
},
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
}
|
||||
|
||||
|
||||
def test_tty_environment_logout_explains_that_no_action_was_taken(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_environment_logout(json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Horizon Account" in output
|
||||
assert "This session uses HORIZON_API_KEY." in output
|
||||
assert "Remove it from your environment to sign out." in output
|
||||
assert "No credential was revoked or removed." in output
|
||||
|
||||
|
||||
def test_json_environment_logout_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_environment_logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
|
||||
|
||||
def test_tty_logout_uses_the_horizon_header(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_logout(remote_revoked=True, json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Logged out of Horizon" in output
|
||||
assert "│" in output
|
||||
|
||||
|
||||
def test_json_logout_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_logout(remote_revoked=True, json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": True,
|
||||
}
|
||||
|
|
@ -35,6 +35,20 @@ class TestMainCLI:
|
|||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@pytest.mark.parametrize("name", ["login", "logout", "whoami"])
|
||||
def test_horizon_account_commands_are_top_level(self, name: str):
|
||||
command, bound, _ = app.parse_args([name, "--json"])
|
||||
|
||||
assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
assert bound.arguments == {"json_output": True}
|
||||
|
||||
def test_login_accepts_a_horizon_host(self):
|
||||
_, bound, _ = app.parse_args(
|
||||
["login", "--host", "https://dev.horizon.prefect.io"]
|
||||
)
|
||||
|
||||
assert bound.arguments == {"host": "https://dev.horizon.prefect.io"}
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the version command."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue