fix: preserve Horizon command contracts

This commit is contained in:
Edward Park 2026-08-07 23:50:52 -07:00
commit e532e740be
5 changed files with 40 additions and 6 deletions

View file

@ -94,9 +94,11 @@ fastmcp whoami
fastmcp logout
```
`fastmcp login` always shows a verification URL and code.
`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
When login needs a new key, it shows a verification URL and code.
It opens a browser when the terminal supports it.
If the browser does not open, use the shown URL and code on another device.
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
Use `--host` to connect to another Horizon environment.
The CLI saves the host and clears the stored key before a host change.

View file

@ -21,8 +21,10 @@ Sign in to Horizon from the FastMCP CLI with the device authorization flow.
fastmcp login
```
The command shows a verification URL and code before it opens the browser.
The command uses an environment key or a valid stored key when one is available.
When login needs a new key, it shows a verification URL and code before it opens the browser.
If the browser cannot open, visit the shown URL and enter the code.
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
New users can register and create their first Horizon organization in the browser.
Use `fastmcp login --host <url>` to save a different Horizon host.
A host change clears the stored credential before login.

View file

@ -216,6 +216,7 @@ async def login(
credential = await resolve_credential(
credentials,
authorize=device_authorization,
expected_api_origin=configuration.api_origin,
)
try:
@ -234,6 +235,7 @@ async def login(
credential = await resolve_credential(
credentials,
authorize=device_authorization,
expected_api_origin=configuration.api_origin,
)
try:
user = await _get_user(
@ -277,7 +279,14 @@ async def whoami(
)
except HorizonUnauthorizedError as error:
if credential is not None and credential.source == "stored":
credentials.clear()
try:
credentials.clear()
except StateFileError as cleanup_error:
_fail_for_expected_error(
"whoami",
cleanup_error,
json_output=json_output,
)
_fail_for_expected_error("whoami", error, json_output=json_output)
except (
AuthenticationRequiredError,

View file

@ -13,6 +13,7 @@ import fastmcp.cli.deploy.command as command_module
from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import HorizonClient
from fastmcp.cli.deploy.state import StateFileError
class HorizonAuthAPI:
@ -259,6 +260,26 @@ async def test_login_never_persists_an_environment_key(
)
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
def fail_clear(store: CredentialStore) -> None:
raise StateFileError("cleanup failed")
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "state_error"
async def test_json_whoami_does_not_start_device_authorization(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,

View file

@ -51,7 +51,7 @@ def test_tty_device_challenge_uses_the_sign_in_layout(
emit_device_challenge(authorization(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "" in output
assert "Deploy FastMCP on Horizon" in output
assert "✓ Device authorization started" in output
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
@ -82,7 +82,7 @@ def test_tty_identity_uses_an_account_panel(
emit_identity("whoami", user(), json_output=False)
output = capsys.readouterr().out
assert "" in output
assert "" in output
assert "Horizon Account" in output
assert "Ada" in output
assert "ada@example.com" in output
@ -124,7 +124,7 @@ def test_tty_logout_uses_the_horizon_header(
output = capsys.readouterr().out
assert "Logged out of Horizon" in output
assert "" in output
assert "" in output
def test_json_logout_has_stable_fields(