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 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. It opens a browser when the terminal supports it.
If the browser does not open, use the shown URL and code on another device. 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. Use `--host` to connect to another Horizon environment.
The CLI saves the host and clears the stored key before a host change. 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 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. 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. New users can register and create their first Horizon organization in the browser.
Use `fastmcp login --host <url>` to save a different Horizon host. Use `fastmcp login --host <url>` to save a different Horizon host.
A host change clears the stored credential before login. A host change clears the stored credential before login.

View file

@ -216,6 +216,7 @@ async def login(
credential = await resolve_credential( credential = await resolve_credential(
credentials, credentials,
authorize=device_authorization, authorize=device_authorization,
expected_api_origin=configuration.api_origin,
) )
try: try:
@ -234,6 +235,7 @@ async def login(
credential = await resolve_credential( credential = await resolve_credential(
credentials, credentials,
authorize=device_authorization, authorize=device_authorization,
expected_api_origin=configuration.api_origin,
) )
try: try:
user = await _get_user( user = await _get_user(
@ -277,7 +279,14 @@ async def whoami(
) )
except HorizonUnauthorizedError as error: except HorizonUnauthorizedError as error:
if credential is not None and credential.source == "stored": 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) _fail_for_expected_error("whoami", error, json_output=json_output)
except ( except (
AuthenticationRequiredError, 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.command import login, logout, whoami
from fastmcp.cli.deploy.credentials import CredentialStore from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import HorizonClient from fastmcp.cli.deploy.horizon_client import HorizonClient
from fastmcp.cli.deploy.state import StateFileError
class HorizonAuthAPI: 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( async def test_json_whoami_does_not_start_device_authorization(
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch, 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) emit_device_challenge(authorization(), json_output=False)
output = capsys.readouterr().out output = capsys.readouterr().out
assert "" in output assert "" in output
assert "Deploy FastMCP on Horizon" in output assert "Deploy FastMCP on Horizon" in output
assert "✓ Device authorization started" in output assert "✓ Device authorization started" in output
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" 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) emit_identity("whoami", user(), json_output=False)
output = capsys.readouterr().out output = capsys.readouterr().out
assert "" in output assert "" in output
assert "Horizon Account" in output assert "Horizon Account" in output
assert "Ada" in output assert "Ada" in output
assert "ada@example.com" in output assert "ada@example.com" in output
@ -124,7 +124,7 @@ def test_tty_logout_uses_the_horizon_header(
output = capsys.readouterr().out output = capsys.readouterr().out
assert "Logged out of Horizon" in output assert "Logged out of Horizon" in output
assert "" in output assert "" in output
def test_json_logout_has_stable_fields( def test_json_logout_has_stable_fields(