This commit is contained in:
Eddie 2026-08-07 23:50:59 -07:00 committed by GitHub
commit 39271eb0f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1212 additions and 0 deletions

View file

@ -28,6 +28,9 @@ fastmcp --help
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
| `login` | Sign in to Prefect Horizon with a browser device flow |
| `whoami` | Show the current Horizon account |
| `logout` | Revoke the current Horizon key and remove the local credential |
| `version` | Print version info (`--copy` to copy to clipboard) |
## Server Targets
@ -81,6 +84,43 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
## Authentication
### Prefect Horizon Account
Use the top-level account commands to manage the credential for Prefect Horizon.
```bash
fastmcp login
fastmcp whoami
fastmcp logout
```
`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
When login needs a new key, it shows a verification URL and code.
It opens a browser when the terminal supports it.
If the browser does not open, use the shown URL and code on another device.
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
Use `--host` to connect to another Horizon environment.
The CLI saves the host and clears the stored key before a host change.
```bash
fastmcp login --host https://horizon.example.com
```
Login stores only the personal Horizon API key.
It does not select or store a deployment organization.
`fastmcp whoami` gets the current user from Horizon.
`fastmcp logout` attempts to revoke the active key and always removes the local credential.
Set `HORIZON_API_KEY` to use an environment credential instead.
The CLI gives that value first precedence and never stores it.
Use `--json` for stable command results.
During JSON login, the verification challenge goes to stderr and the final result goes to stdout.
JSON mode does not open a browser or ask a question.
### MCP Server Authentication
When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
To skip authentication entirely — useful for local development servers — pass `--auth none`:

View file

@ -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.
Use `fastmcp login --host <url>` to save a different Horizon host.
A host change clears the stored credential before login.
Check the active account after login.
```bash
fastmcp whoami
```
Remove the local credential and revoke the active personal API key when possible.
```bash
fastmcp logout
```
Login does not select or store a deployment organization.
For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key.
The CLI never writes the environment value to its credential file.
## The Platform
Horizon is organized into four integrated pillars:

View file

@ -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()

View file

@ -0,0 +1,358 @@
"""Public Prefect Horizon authentication commands."""
from __future__ import annotations
import platform
import sys
import webbrowser
from typing import Annotated, NoReturn
from cyclopts import Parameter
from rich.status import Status
import fastmcp
from fastmcp.cli.deploy.authentication import (
DeviceAuthorizationDeniedError,
DeviceAuthorizationError,
DeviceAuthorizationExpiredError,
authorize_device,
)
from fastmcp.cli.deploy.configuration import ConfigurationStore
from fastmcp.cli.deploy.credentials import (
AuthenticationRequiredError,
CredentialStore,
ResolvedCredential,
resolve_credential,
revoke_and_clear_credential,
)
from fastmcp.cli.deploy.horizon_client import (
DeviceAuthorization,
DeviceMetadata,
HorizonClient,
HorizonResponseError,
HorizonUnauthorizedError,
HorizonUnavailableError,
HorizonUser,
)
from fastmcp.cli.deploy.output import (
CommandName,
ErrorCategory,
emit_device_challenge,
emit_error,
emit_identity,
emit_logout,
start_device_approval_status,
stop_device_approval_status,
)
from fastmcp.cli.deploy.state import StateFileError
JsonOption = Annotated[
bool,
Parameter(
name="--json",
help="Write one final JSON result to stdout",
negative=(),
),
]
HostOption = Annotated[
str | None,
Parameter(
name="--host",
help="Use and save a different Horizon host URL",
),
]
def _can_open_browser() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def _device_metadata() -> DeviceMetadata:
return DeviceMetadata(
device_name=platform.node() or None,
platform=platform.system().lower() or None,
architecture=platform.machine().lower() or None,
client_version=fastmcp.__version__,
)
def _fail(
command: CommandName,
category: ErrorCategory,
message: str,
*,
json_output: bool,
details: dict[str, object] | None = None,
) -> NoReturn:
emit_error(
command,
category,
message,
json_output=json_output,
details=details,
)
raise SystemExit(1)
def _fail_for_expected_error(
command: CommandName,
error: Exception,
*,
json_output: bool,
) -> NoReturn:
if isinstance(error, AuthenticationRequiredError):
_fail(
command,
"authentication_required",
"Run `fastmcp login` to sign in to Prefect Horizon.",
json_output=json_output,
)
if isinstance(error, HorizonUnauthorizedError):
_fail(
command,
"authentication_invalid",
"The Horizon credential is not valid. Run `fastmcp login` again.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationDeniedError):
_fail(
command,
"authorization_denied",
"The device authorization request was denied.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationExpiredError):
_fail(
command,
"authorization_expired",
"The device authorization request expired. Run the command again.",
json_output=json_output,
)
if isinstance(error, DeviceAuthorizationError):
_fail(
command,
"authorization_failed",
"The device authorization request failed. Run the command again.",
json_output=json_output,
)
if isinstance(error, HorizonUnavailableError):
_fail(
command,
"horizon_unavailable",
"The Horizon API is unavailable. Try again later.",
json_output=json_output,
)
if isinstance(error, HorizonResponseError):
_fail(
command,
"horizon_error",
"Horizon returned an unexpected response. Try again later.",
json_output=json_output,
)
if isinstance(error, StateFileError):
_fail(
command,
"state_error",
"The local Horizon state is invalid.",
json_output=json_output,
)
raise error
async def _get_user(
api_origin: str,
credential: ResolvedCredential,
) -> HorizonUser:
async with HorizonClient(api_origin, api_key=credential.api_key) as client:
return await client.get_current_user()
async def login(
*,
host: HostOption = None,
json_output: JsonOption = False,
) -> None:
"""Sign in to Prefect Horizon."""
credentials = CredentialStore()
try:
configuration_store = ConfigurationStore()
if host is None:
configuration = configuration_store.load()
else:
try:
configuration = configuration_store.set_api_origin(
host,
credentials=credentials,
)
except ValueError:
_fail(
"login",
"invalid_host",
"The Horizon host must be an HTTP origin.",
json_output=json_output,
)
async def device_authorization():
approval_status: Status | None = None
def show_challenge(challenge: DeviceAuthorization) -> None:
nonlocal approval_status
emit_device_challenge(challenge, json_output=json_output)
approval_status = start_device_approval_status(json_output=json_output)
try:
async with HorizonClient(configuration.api_origin) as client:
return await authorize_device(
client,
metadata=_device_metadata(),
on_challenge=show_challenge,
open_browser=not json_output and _can_open_browser(),
browser_opener=webbrowser.open,
)
finally:
stop_device_approval_status(approval_status)
credential = await resolve_credential(
credentials,
authorize=device_authorization,
expected_api_origin=configuration.api_origin,
)
try:
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError:
if credential.source == "interactive":
credentials.clear()
raise
if credential.source == "environment":
raise
credentials.clear()
credential = await resolve_credential(
credentials,
authorize=device_authorization,
expected_api_origin=configuration.api_origin,
)
try:
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError:
credentials.clear()
raise
except (
AuthenticationRequiredError,
DeviceAuthorizationError,
HorizonResponseError,
HorizonUnauthorizedError,
HorizonUnavailableError,
StateFileError,
) as error:
_fail_for_expected_error("login", error, json_output=json_output)
emit_identity(
"login",
user,
json_output=json_output,
)
async def whoami(
*,
json_output: JsonOption = False,
) -> None:
"""Show the current Prefect Horizon user."""
credentials = CredentialStore()
credential: ResolvedCredential | None = None
try:
configuration = ConfigurationStore().load()
credential = await resolve_credential(credentials)
user = await _get_user(
configuration.api_origin,
credential,
)
except HorizonUnauthorizedError as error:
if credential is not None and credential.source == "stored":
try:
credentials.clear()
except StateFileError as cleanup_error:
_fail_for_expected_error(
"whoami",
cleanup_error,
json_output=json_output,
)
_fail_for_expected_error("whoami", error, json_output=json_output)
except (
AuthenticationRequiredError,
HorizonResponseError,
HorizonUnavailableError,
StateFileError,
) as error:
_fail_for_expected_error("whoami", error, json_output=json_output)
emit_identity(
"whoami",
user,
json_output=json_output,
)
async def logout(
*,
json_output: JsonOption = False,
) -> None:
"""Revoke the current Horizon key and remove the local credential."""
credentials = CredentialStore()
try:
configuration = ConfigurationStore().load()
credential = await resolve_credential(credentials)
except AuthenticationRequiredError:
emit_logout(remote_revoked=False, json_output=json_output)
return
except StateFileError:
try:
credentials.clear()
except StateFileError as error:
_fail_for_expected_error("logout", error, json_output=json_output)
_fail(
"logout",
"remote_revocation_failed",
"The local credential was removed, but the remote key can remain active.",
json_output=json_output,
details={
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
},
)
try:
async with HorizonClient(
configuration.api_origin,
api_key=credential.api_key,
) as client:
await revoke_and_clear_credential(client, credentials)
except HorizonUnauthorizedError:
emit_logout(remote_revoked=False, json_output=json_output)
return
except (HorizonResponseError, HorizonUnavailableError):
_fail(
"logout",
"remote_revocation_failed",
"The local credential was removed, but the remote key can remain active.",
json_output=json_output,
details={
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
},
)
except StateFileError as error:
_fail_for_expected_error("logout", error, json_output=json_output)
emit_logout(remote_revoked=True, json_output=json_output)

View file

@ -0,0 +1,263 @@
"""Stable terminal and JSON output for Horizon CLI commands."""
from __future__ import annotations
import json
import sys
from typing import Literal
from rich import box
from rich.align import Align
from rich.console import Console, Group
from rich.padding import Padding
from rich.panel import Panel
from rich.status import Status
from rich.table import Table
from rich.text import Text
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
CommandName = Literal["login", "logout", "whoami"]
ErrorCategory = Literal[
"authentication_invalid",
"authentication_required",
"authorization_denied",
"authorization_expired",
"authorization_failed",
"horizon_error",
"horizon_unavailable",
"invalid_host",
"remote_revocation_failed",
"state_error",
]
console = Console()
error_console = Console(stderr=True)
def _write_json(payload: object, *, stderr: bool = False) -> None:
stream = sys.stderr if stderr else sys.stdout
print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True)
def _banner(title: str, *, style: str) -> Panel:
return Panel(
Align.center(Text(title, style=f"bold {style}")),
box=box.ROUNDED,
border_style=style,
padding=(0, 1),
width=52,
)
def _account_panel(
user: HorizonUser,
*,
title: str,
message: str,
) -> Panel:
name = Text(user.name or user.email, style="bold")
details: list[Text] = [name]
if user.name:
details.append(Text(user.email, style="cyan"))
details.extend([Text(), Text(message, style="green")])
return Panel(
Group(*details),
title=Text(title, style="bold green"),
title_align="left",
box=box.ROUNDED,
border_style="green",
padding=(1, 2),
width=52,
)
def _format_duration(seconds: int) -> str:
if seconds % 60 == 0:
minutes = seconds // 60
unit = "minute" if minutes == 1 else "minutes"
return f"{minutes} {unit}"
unit = "second" if seconds == 1 else "seconds"
return f"{seconds} {unit}"
def emit_device_challenge(
authorization: DeviceAuthorization,
*,
json_output: bool,
) -> None:
"""Show a device challenge before polling starts."""
if json_output:
_write_json(
{
"event": "device_authorization",
"verificationUrl": authorization.verification_uri,
"verificationUrlComplete": authorization.verification_uri_complete,
"userCode": authorization.user_code,
},
stderr=True,
)
return
console.print()
console.print(_banner("Deploy FastMCP on Horizon", style="magenta"))
console.print()
console.print(Text("✓ Device authorization started", style="bold green"))
console.print()
console.print(" Open this URL in your browser:")
console.print()
console.print(
Padding(
Text(authorization.verification_uri_complete, style="cyan underline"),
(0, 2),
)
)
console.print()
console.print(" Confirm this code:")
console.print()
code = Table.grid()
code.add_column(justify="center", width=52)
code.add_row(Text(authorization.user_code, style="bold"))
console.print(code)
console.print()
expires_in = _format_duration(authorization.expires_in)
console.print(Text(f"The request expires in {expires_in}.", style="dim"))
console.print(Text("Press Ctrl-C to cancel.", style="dim"))
console.print()
def start_device_approval_status(*, json_output: bool) -> Status | None:
"""Start the terminal spinner while the browser approval is pending."""
if json_output:
return None
status = console.status(
"[cyan]Waiting for approval in your browser[/cyan]",
spinner="dots",
spinner_style="cyan",
)
status.start()
return status
def stop_device_approval_status(status: Status | None) -> None:
"""Stop a device approval spinner when one is active."""
if status is not None:
status.stop()
def emit_identity(
command: Literal["login", "whoami"],
user: HorizonUser,
*,
json_output: bool,
) -> None:
"""Show the authenticated user."""
if json_output:
_write_json(
{
"ok": True,
"command": command,
"user": user.model_dump(mode="json"),
}
)
return
console.print()
if command == "login":
panel = _account_panel(
user,
title="Logged into Horizon",
message="You are signed in to FastMCP.",
)
else:
panel = _account_panel(
user,
title="Horizon Account",
message="● Signed in",
)
console.print(panel)
console.print()
def emit_logout(
*,
remote_revoked: bool,
json_output: bool,
) -> None:
"""Show a successful local logout result."""
if json_output:
_write_json(
{
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": remote_revoked,
}
)
return
if remote_revoked:
title = "Logged out of Horizon"
message = "The Horizon credential was revoked and removed from this device."
style = "green"
else:
title = "Horizon Account"
message = "No active Horizon credential remains on this device."
style = "cyan"
console.print()
console.print(
Panel(
Text(message),
title=Text(title, style=f"bold {style}"),
title_align="left",
box=box.ROUNDED,
border_style=style,
padding=(1, 2),
width=60,
)
)
console.print()
def emit_error(
command: CommandName,
category: ErrorCategory,
message: str,
*,
json_output: bool,
details: dict[str, object] | None = None,
) -> None:
"""Show a stable expected command failure."""
if json_output:
payload: dict[str, object] = {
"ok": False,
"command": command,
"error": {
"category": category,
"message": message,
},
}
if details:
payload.update(details)
_write_json(payload)
return
titles = {
"login": "✗ Sign in failed",
"logout": "✗ Sign out failed",
"whoami": "✗ Account lookup failed",
}
error_console.print()
error_console.print(
Panel(
Text(message),
title=Text(titles[command], style="bold red"),
title_align="left",
box=box.ROUNDED,
border_style="red",
padding=(1, 2),
width=60,
)
)
error_console.print()

View file

@ -0,0 +1,358 @@
import json
from collections.abc import Callable
from unittest.mock import Mock
from urllib.parse import parse_qs
import httpx2
import pytest
from pydantic import SecretStr
import fastmcp
import fastmcp.cli.deploy.authentication as authentication_module
import fastmcp.cli.deploy.command as command_module
from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import HorizonClient
from fastmcp.cli.deploy.state import StateFileError
class HorizonAuthAPI:
def __init__(
self,
*,
token_error: str | None = None,
revoke_status: int = 204,
invalid_api_key: str | None = None,
) -> None:
self.token_error = token_error
self.revoke_status = revoke_status
self.invalid_api_key = invalid_api_key
self.requests: list[httpx2.Request] = []
def __call__(self, request: httpx2.Request) -> httpx2.Response:
self.requests.append(request)
path = request.url.path
if path == "/api/v0/oauth/device/authorization":
return httpx2.Response(
200,
json={
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://horizon.prefect.io/oauth/device",
"verification_uri_complete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"expires_in": 600,
"interval": 1,
},
)
if path == "/api/v0/oauth/device/token":
if self.token_error is not None:
return httpx2.Response(400, json={"error": self.token_error})
return httpx2.Response(
200,
json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
)
if path == "/api/v0/me":
if request.headers.get("Authorization") == (
f"Bearer {self.invalid_api_key}"
):
return httpx2.Response(401)
return httpx2.Response(
200,
json={
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
}
},
)
if path == "/api/v0/me/api-key":
return httpx2.Response(self.revoke_status)
raise AssertionError(f"Unexpected request: {request.method} {path}")
@pytest.fixture
def use_horizon_api(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[HorizonAuthAPI], None]:
def use(api: HorizonAuthAPI) -> None:
transport = httpx2.MockTransport(api)
def client(
api_origin: str,
*,
api_key: SecretStr | str | None = None,
) -> HorizonClient:
return HorizonClient(
api_origin,
api_key=api_key,
transport=transport,
)
monkeypatch.setattr(command_module, "HorizonClient", client)
return use
@pytest.fixture(autouse=True)
def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
async def sleep(_: float) -> None:
return None
monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
async def test_json_login_writes_one_result_and_challenge_to_stderr(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
await login(json_output=True)
captured = capsys.readouterr()
stdout_lines = captured.out.strip().splitlines()
assert len(stdout_lines) == 1
assert json.loads(stdout_lines[0]) == {
"ok": True,
"command": "login",
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
},
}
assert json.loads(captured.err) == {
"event": "device_authorization",
"verificationUrl": "https://horizon.prefect.io/oauth/device",
"verificationUrlComplete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"userCode": "ABCD-EFGH",
}
browser_open.assert_not_called()
authorization_request = next(
request
for request in api.requests
if request.url.path == "/api/v0/oauth/device/authorization"
)
assert parse_qs(authorization_request.content.decode()) == {
"client_id": ["fastmcp-cli"],
"device_name": ["Avery's laptop"],
"platform": ["darwin"],
"architecture": ["arm64"],
"client_version": ["4.0.0"],
}
state = json.loads(CredentialStore().path.read_text())
assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
async def test_login_host_is_saved_before_device_authorization(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(host="https://dev.horizon.prefect.io/", json_output=True)
assert json.loads(capsys.readouterr().out)["ok"] is True
configuration_path = fastmcp.settings.home / "cli" / "config.json"
assert json.loads(configuration_path.read_text()) == {
"schemaVersion": 1,
"apiOrigin": "https://dev.horizon.prefect.io",
}
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
async def test_login_rejects_an_invalid_host(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit, match="1"):
await login(host="https://horizon.prefect.io/path", json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "invalid_host"
assert CredentialStore().path.exists() is False
async def test_tty_login_survives_browser_open_failure(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI())
browser_open = Mock(side_effect=OSError("No browser"))
monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
await login()
output = capsys.readouterr().out
assert "https://horizon.prefect.io/oauth/device" in output
assert "ABCD-EFGH" in output
assert "Logged into Horizon" in output
assert "Ada" in output
assert "ada@example.com" in output
assert "Organization" not in output
browser_open.assert_called_once()
async def test_whoami_uses_the_stored_key_after_a_restart(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(json_output=True)
capsys.readouterr()
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["command"] == "whoami"
assert result["user"]["email"] == "ada@example.com"
assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
async def test_login_replaces_an_invalid_stored_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
await login(json_output=True)
captured = capsys.readouterr()
assert json.loads(captured.out)["ok"] is True
assert json.loads(captured.err)["event"] == "device_authorization"
stored_key = CredentialStore().load()
assert stored_key is not None
assert stored_key.get_secret_value() == "fmcp_device_key"
async def test_login_never_persists_an_environment_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
await login(json_output=True)
assert CredentialStore().path.exists() is False
assert not any(
request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
)
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
def fail_clear(store: CredentialStore) -> None:
raise StateFileError("cleanup failed")
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "state_error"
async def test_json_whoami_does_not_start_device_authorization(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "authentication_required"
browser_open.assert_not_called()
@pytest.mark.parametrize(
("token_error", "category"),
[
("access_denied", "authorization_denied"),
("expired_token", "authorization_expired"),
],
)
async def test_json_login_reports_stable_device_failures(
token_error: str,
category: str,
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(token_error=token_error))
with pytest.raises(SystemExit, match="1"):
await login(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == category
assert CredentialStore().path.exists() is False
async def test_logout_revokes_the_remote_key_and_clears_local_state(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
CredentialStore().save("fmcp_stored_key")
await logout(json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": True,
}
assert CredentialStore().path.exists() is False
assert any(
request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
for request in api.requests
)
async def test_logout_clears_local_state_when_remote_revocation_fails(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(revoke_status=503))
CredentialStore().save("fmcp_stored_key")
with pytest.raises(SystemExit, match="1"):
await logout(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "remote_revocation_failed"
assert result["localCredentialRemoved"] is True
assert result["remoteCredentialMayRemain"] is True
assert CredentialStore().path.exists() is False

View file

@ -0,0 +1,140 @@
import json
import pytest
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
from fastmcp.cli.deploy.output import (
emit_device_challenge,
emit_error,
emit_identity,
emit_logout,
)
def authorization() -> DeviceAuthorization:
return DeviceAuthorization(
device_code="device-secret",
user_code="ABCD-EFGH",
verification_uri="https://horizon.prefect.io/oauth/device",
verification_uri_complete=(
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
expires_in=600,
interval=5,
)
def user() -> HorizonUser:
return HorizonUser(id="user-1", email="ada@example.com", name="Ada")
def test_json_device_challenge_uses_only_stderr(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_device_challenge(authorization(), json_output=True)
captured = capsys.readouterr()
assert captured.out == ""
assert json.loads(captured.err) == {
"event": "device_authorization",
"verificationUrl": "https://horizon.prefect.io/oauth/device",
"verificationUrlComplete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"userCode": "ABCD-EFGH",
}
def test_tty_device_challenge_uses_the_sign_in_layout(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_device_challenge(authorization(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "Deploy FastMCP on Horizon" in output
assert "✓ Device authorization started" in output
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
assert "ABCD-EFGH" in output
assert "The request expires in 10 minutes." in output
def test_json_identity_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_identity("login", user(), json_output=True)
result = json.loads(capsys.readouterr().out)
assert result == {
"ok": True,
"command": "login",
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
},
}
def test_tty_identity_uses_an_account_panel(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_identity("whoami", user(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "Horizon Account" in output
assert "Ada" in output
assert "ada@example.com" in output
assert "● Signed in" in output
assert "Organization" not in output
def test_json_error_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_error(
"logout",
"remote_revocation_failed",
"The remote key can remain active.",
json_output=True,
details={
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
},
)
result = json.loads(capsys.readouterr().out)
assert result == {
"ok": False,
"command": "logout",
"error": {
"category": "remote_revocation_failed",
"message": "The remote key can remain active.",
},
"localCredentialRemoved": True,
"remoteCredentialMayRemain": True,
}
def test_tty_logout_uses_the_horizon_header(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_logout(remote_revoked=True, json_output=False)
output = capsys.readouterr().out
assert "Logged out of Horizon" in output
assert "" in output
def test_json_logout_has_stable_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
emit_logout(remote_revoked=True, json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": True,
}

View file

@ -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."""