mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
feat: refine Horizon account output
This commit is contained in:
parent
e877785511
commit
0c955d4828
6 changed files with 208 additions and 91 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ New users can register and create their first Horizon organization in the browse
|
|||
Use `fastmcp login --host <url>` 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 <ada@example.com>." 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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <ada@example.com>." 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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue