From a5bdba39c33bf5944926f71fe68b7055bcb19221 Mon Sep 17 00:00:00 2001 From: loookashow Date: Mon, 10 Aug 2026 10:17:45 +0200 Subject: [PATCH 1/2] feat(management): Flux API key bearer tokens; release 0.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted MCP connectors accept a single token value and send it as `Authorization: Bearer `; the scheme is not selectable. Flux accepts only `Simple` and `Secure`, so every authentication-required Flux API was unreachable from them — and since writes always require an authenticated key, that ruled out agent memory entirely. Adds the two Management API calls that manage the credential, on both the sync and the async client: - `issue_flux_api_key_bearer_token(key)` issues or replaces the token and returns the plaintext. It is returned only here and only once: the service stores a hash, exactly as for `secret_key`, so a lost token is re-issued rather than recovered. - `revoke_flux_api_key_bearer_token(key)` revokes it. Both address the token sub-resource, never the key. Issuing, re-issuing and revoking all leave `public_key`, `secret_key`, `role` and grants untouched, so `Simple` and `Secure` integrations keep working — that is what makes a re-issue a way to cut off a connector without recreating a key and reconfiguring everything that uses it. `FluxAPIKeySummary` gains `bearer_token_prefix` and `bearer_token_issued_at`, both OPTIONAL so the model still validates a response from a server that predates the feature — the SDK ships ahead of the deployment. The prefix is the first 12 characters: enough to recognise a token in a config file, never enough to use one. Tests cover both clients and pin that each call reaches the sub-resource and not the key's own URL. A request to the key URL would delete the key and take its Simple/Secure credentials down with it, which is the one mistake here that fails silently. Requires a server with bearer-token support; against an older one both methods return 404. Version bumped in src/foxnose_sdk/_version.py, the single source the build backend reads, and in the test that pins it. The changelog entry goes in docs/changelog.md, which is the maintained one — it is published through mkdocs and carries 0.7.0 and 0.7.1. The root CHANGELOG.md stopped at 0.6.0 some releases ago and is left untouched here rather than half revived. --- docs/changelog.md | 31 +++++++++++- src/foxnose_sdk/__init__.py | 2 + src/foxnose_sdk/_version.py | 2 +- src/foxnose_sdk/management/client.py | 68 +++++++++++++++++++++++++++ src/foxnose_sdk/management/models.py | 26 ++++++++++ tests/test_async_clients.py | 35 ++++++++++++++ tests/test_clients.py | 59 +++++++++++++++++++++++ tests/test_collection_type_aliases.py | 2 +- 8 files changed, 222 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 705c9df..c236839 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-08-10 + +### Added + +- **Flux API key bearer tokens.** An opaque `fxk_` credential bound to a Flux + API key, for hosted MCP connectors that accept only a token value and send it + as `Authorization: Bearer ` — the Claude API's `mcp_servers` among + them. Those clients cannot choose a scheme, so `Simple` and `Secure` are + unreachable from them and every authentication-required Flux API was out of + reach, taking MCP writes with it. + - `ManagementClient.issue_flux_api_key_bearer_token(key)` (and the async + twin) — issues or replaces the token and returns the plaintext. **Returned + only here, and only once**: the service stores a hash, so a lost token is + re-issued, not recovered. + - `ManagementClient.revoke_flux_api_key_bearer_token(key)` (and the async + twin) — revokes it. The key, its role and its `Simple`/`Secure` credentials + are untouched, which is equally true of a re-issue: that is what makes this + a way to cut off a connector without recreating a key. + - `FluxAPIKeyBearerToken` model for the one-time response. + - `FluxAPIKeySummary` gains optional `bearer_token_prefix` and + `bearer_token_issued_at`. Optional so the model still validates against a + server predating the feature; the prefix is the first 12 characters — + enough to recognise a token in a config file, never enough to use one. + + Requires a server with bearer-token support; against an older one the two new + methods return 404. + + ## [0.7.1] - 2026-07-24 ### Added @@ -182,7 +210,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error handling guide - Code examples -[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...HEAD +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...v0.8.0 [0.7.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 diff --git a/src/foxnose_sdk/__init__.py b/src/foxnose_sdk/__init__.py index 4b99115..dc70821 100644 --- a/src/foxnose_sdk/__init__.py +++ b/src/foxnose_sdk/__init__.py @@ -73,6 +73,7 @@ FolderList, FolderSummary, FluxAPIKeyList, + FluxAPIKeyBearerToken, FluxAPIKeySummary, ManagementAPIKeyList, ManagementAPIKeySummary, @@ -163,6 +164,7 @@ "EnvironmentList", "ManagementAPIKeySummary", "ManagementAPIKeyList", + "FluxAPIKeyBearerToken", "FluxAPIKeySummary", "FluxAPIKeyList", "ManagementRoleSummary", diff --git a/src/foxnose_sdk/_version.py b/src/foxnose_sdk/_version.py index 18fcd79..b7c7fe2 100644 --- a/src/foxnose_sdk/_version.py +++ b/src/foxnose_sdk/_version.py @@ -4,4 +4,4 @@ by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``). """ -__version__ = "0.7.1" +__version__ = "0.8.0" diff --git a/src/foxnose_sdk/management/client.py b/src/foxnose_sdk/management/client.py index 71168ac..03308ec 100644 --- a/src/foxnose_sdk/management/client.py +++ b/src/foxnose_sdk/management/client.py @@ -28,6 +28,7 @@ FieldList, FieldSummary, FluxAPIKeyList, + FluxAPIKeyBearerToken, FluxAPIKeySummary, FluxRoleList, FluxRoleSummary, @@ -238,6 +239,9 @@ def _flux_api_keys_root(self) -> str: def _flux_api_key_root(self, api_key: str) -> str: return f"{self._flux_api_keys_root()}/{api_key}" + def _flux_api_key_bearer_token_root(self, api_key: str) -> str: + return f"{self._flux_api_key_root(api_key)}/bearer-token" + # API management paths def _apis_root(self) -> str: return f"/v1/{self.environment_key}/api" @@ -547,6 +551,53 @@ def delete_flux_api_key(self, key: FluxAPIKeyRef) -> None: key = _resolve_key(key) self.request("DELETE", f"{self._flux_api_key_root(key)}/", parse_json=False) + def issue_flux_api_key_bearer_token( + self, key: FluxAPIKeyRef + ) -> FluxAPIKeyBearerToken: + """Issue a bearer token for a Flux API key, or replace the existing one. + + A bearer token exists for clients that accept a single token value and + send it as ``Authorization: Bearer `` with no way to choose the + scheme — hosted MCP connectors, notably the Claude API's + ``mcp_servers``. Those clients cannot send ``Simple`` or ``Secure`` at + all, which put every authentication-required Flux API out of their reach. + + THE PLAINTEXT IS RETURNED ONLY HERE, AND ONLY ONCE. The service stores a + hash, exactly as it does for ``secret_key``, so a lost token is re-issued + rather than recovered. Later key reads expose only + ``bearer_token_prefix``. + + The key itself is untouched: ``public_key``, ``secret_key``, ``role`` and + grants all survive, so ``Simple`` and ``Secure`` integrations keep + working across a re-issue. That is what makes this the way to cut off a + connector without recreating a key and reconfiguring everything using it. + + There is at most one token per key; calling this again replaces it. + + Args: + key: Unique identifier of the API key. + """ + key = _resolve_key(key) + data = self.request("POST", f"{self._flux_api_key_bearer_token_root(key)}/") + return FluxAPIKeyBearerToken.model_validate(data) + + def revoke_flux_api_key_bearer_token(self, key: FluxAPIKeyRef) -> None: + """Revoke a Flux API key's bearer token. The key keeps working. + + Only the token is removed — the key, its role and its ``Simple`` / + ``Secure`` credentials are untouched. Idempotent: succeeds whether or not + a token was issued. + + Args: + key: Unique identifier of the API key. + """ + key = _resolve_key(key) + self.request( + "DELETE", + f"{self._flux_api_key_bearer_token_root(key)}/", + parse_json=False, + ) + # ------------------------------------------------------------------ # # API management operations # ------------------------------------------------------------------ # @@ -2966,6 +3017,23 @@ async def delete_flux_api_key(self, key: FluxAPIKeyRef) -> None: "DELETE", f"{self._flux_api_key_root(key)}/", parse_json=False ) + async def issue_flux_api_key_bearer_token( + self, key: FluxAPIKeyRef + ) -> FluxAPIKeyBearerToken: + key = _resolve_key(key) + data = await self.request( + "POST", f"{self._flux_api_key_bearer_token_root(key)}/" + ) + return FluxAPIKeyBearerToken.model_validate(data) + + async def revoke_flux_api_key_bearer_token(self, key: FluxAPIKeyRef) -> None: + key = _resolve_key(key) + await self.request( + "DELETE", + f"{self._flux_api_key_bearer_token_root(key)}/", + parse_json=False, + ) + # ------------------------------------------------------------------ # # API management operations (async) # ------------------------------------------------------------------ # diff --git a/src/foxnose_sdk/management/models.py b/src/foxnose_sdk/management/models.py index 5799d60..7594377 100644 --- a/src/foxnose_sdk/management/models.py +++ b/src/foxnose_sdk/management/models.py @@ -311,11 +311,37 @@ class FluxAPIKeySummary(BaseModel): role: str | None = None environment: str created_at: datetime + #: First 12 characters of the key's bearer token (e.g. ``fxk_A7fQ2mXe``), or + #: None when none is issued. Enough to recognise a token in a config file or + #: a log; never enough to use one — the token itself is returned only by + #: :meth:`ManagementClient.issue_flux_api_key_bearer_token`, once. + #: Optional so the model still validates against a server predating the + #: feature. + bearer_token_prefix: str | None = None + #: When the current bearer token was issued, or None. + bearer_token_issued_at: datetime | None = None FluxAPIKeyList = PaginatedResponse[FluxAPIKeySummary] +class FluxAPIKeyBearerToken(BaseModel): + """The one-time response from issuing or re-issuing a bearer token. + + A bearer token is an opaque credential bound to a Flux API key, for hosted + MCP connectors that accept only a token value and send it as + ``Authorization: Bearer ``. It identifies the key and nothing more: + role, grants and per-collection permissions are the key's own. + """ + + #: The credential, e.g. ``fxk_A7fQ2mXe...``. RETURNED ONLY HERE, ONLY ONCE — + #: the service stores a hash, so a lost token is re-issued, not recovered. + bearer_token: str + #: First 12 characters, also present on every subsequent key read. + bearer_token_prefix: str + bearer_token_issued_at: datetime + + class ManagementRoleSummary(BaseModel): """Represents a management API role.""" diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index a087755..bfbe3b6 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -579,6 +579,41 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() +@pytest.mark.asyncio +async def test_async_flux_api_key_bearer_token_lifecycle(): + """Same contract as the sync client: the sub-resource, never the key.""" + captured: dict[str, Any] = {"paths": []} + token_json = { + "bearer_token": "fxk_A7fQ2mXeKp3vR8sT1uW5yZ2bC6dF9gH0jL4nQ7x", + "bearer_token_prefix": "fxk_A7fQ2mXe", + "bearer_token_issued_at": "2026-08-09T10:24:11.482Z", + } + + def handler(request: httpx.Request) -> httpx.Response: + captured["paths"].append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response(200, json=token_json) + if request.method == "DELETE": + return httpx.Response(204) + raise AssertionError("Unexpected call") + + client = build_async_management_client(handler) + + issued = await client.issue_flux_api_key_bearer_token("flux-key-1") + assert issued.bearer_token == token_json["bearer_token"] + assert captured["paths"][0] == ( + "POST", + "/v1/env123/permissions/flux-api/api-keys/flux-key-1/bearer-token/", + ) + + await client.revoke_flux_api_key_bearer_token("flux-key-1") + method, path = captured["paths"][-1] + assert method == "DELETE" + assert path.endswith("/api-keys/flux-key-1/bearer-token/") + assert not path.endswith("/api-keys/flux-key-1/") + await client.aclose() + + @pytest.mark.asyncio async def test_async_management_role_crud(): captured: list[str] = [] diff --git a/tests/test_clients.py b/tests/test_clients.py index 457783b..7d5f18e 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -673,6 +673,65 @@ def handler(request: httpx.Request) -> httpx.Response: assert captured["paths"][-1][0] == "DELETE" +def test_flux_api_key_bearer_token_lifecycle(): + """Issue and revoke address the SUB-RESOURCE, never the key itself. + + A request to the key's own URL would delete the key and take its + Simple/Secure credentials with it — the token is a separate credential + precisely so it can be replaced without disturbing them. + """ + captured: dict[str, Any] = {"paths": []} + token_json = { + "bearer_token": "fxk_A7fQ2mXeKp3vR8sT1uW5yZ2bC6dF9gH0jL4nQ7x", + "bearer_token_prefix": "fxk_A7fQ2mXe", + "bearer_token_issued_at": "2026-08-09T10:24:11.482Z", + } + + def handler(request: httpx.Request) -> httpx.Response: + captured["paths"].append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response(200, json=token_json) + if request.method == "DELETE": + return httpx.Response(204) + raise AssertionError("Unexpected call") + + client = build_management_client(handler) + + issued = client.issue_flux_api_key_bearer_token("flux-key-1") + assert issued.bearer_token == token_json["bearer_token"] + assert issued.bearer_token_prefix == "fxk_A7fQ2mXe" + assert captured["paths"][0] == ( + "POST", + "/v1/env123/permissions/flux-api/api-keys/flux-key-1/bearer-token/", + ) + + client.revoke_flux_api_key_bearer_token("flux-key-1") + method, path = captured["paths"][-1] + assert method == "DELETE" + assert path.endswith("/api-keys/flux-key-1/bearer-token/") + assert not path.endswith("/api-keys/flux-key-1/") + + +def test_flux_api_key_bearer_fields_are_optional(): + """A server predating the feature omits them; the model must still validate.""" + from foxnose_sdk import FluxAPIKeySummary + + key = FluxAPIKeySummary.model_validate(FLUX_API_KEY_JSON) + assert key.bearer_token_prefix is None + assert key.bearer_token_issued_at is None + + with_token = FluxAPIKeySummary.model_validate( + FLUX_API_KEY_JSON + | { + "bearer_token_prefix": "fxk_A7fQ2mXe", + "bearer_token_issued_at": "2026-08-09T10:24:11.482Z", + } + ) + assert with_token.bearer_token_prefix == "fxk_A7fQ2mXe" + # Only the prefix is ever exposed on a key read — never the credential. + assert not hasattr(with_token, "bearer_token") + + def test_management_role_crud(): captured: list[str] = [] diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py index efc5954..115e650 100644 --- a/tests/test_collection_type_aliases.py +++ b/tests/test_collection_type_aliases.py @@ -54,7 +54,7 @@ def test_top_level_package_reexports_collection_types(): def test_version_string_matches_pyproject(): """Pin the declared package version (single-sourced from _version.py, which the build backend also reads for the distribution version).""" - assert foxnose_sdk.__version__ == "0.7.1" + assert foxnose_sdk.__version__ == "0.8.0" def test_user_agent_tracks_version(): From f2703ad78d95c29dbb1a02ed769b18d9b829ed5b Mon Sep 17 00:00:00 2001 From: loookashow Date: Mon, 10 Aug 2026 10:35:06 +0200 Subject: [PATCH 2/2] chore: move the version bump and release notes to a release PR --- docs/changelog.md | 31 +-------------------------- src/foxnose_sdk/_version.py | 2 +- tests/test_collection_type_aliases.py | 2 +- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index c236839..705c9df 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,34 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.8.0] - 2026-08-10 - -### Added - -- **Flux API key bearer tokens.** An opaque `fxk_` credential bound to a Flux - API key, for hosted MCP connectors that accept only a token value and send it - as `Authorization: Bearer ` — the Claude API's `mcp_servers` among - them. Those clients cannot choose a scheme, so `Simple` and `Secure` are - unreachable from them and every authentication-required Flux API was out of - reach, taking MCP writes with it. - - `ManagementClient.issue_flux_api_key_bearer_token(key)` (and the async - twin) — issues or replaces the token and returns the plaintext. **Returned - only here, and only once**: the service stores a hash, so a lost token is - re-issued, not recovered. - - `ManagementClient.revoke_flux_api_key_bearer_token(key)` (and the async - twin) — revokes it. The key, its role and its `Simple`/`Secure` credentials - are untouched, which is equally true of a re-issue: that is what makes this - a way to cut off a connector without recreating a key. - - `FluxAPIKeyBearerToken` model for the one-time response. - - `FluxAPIKeySummary` gains optional `bearer_token_prefix` and - `bearer_token_issued_at`. Optional so the model still validates against a - server predating the feature; the prefix is the first 12 characters — - enough to recognise a token in a config file, never enough to use one. - - Requires a server with bearer-token support; against an older one the two new - methods return 404. - - ## [0.7.1] - 2026-07-24 ### Added @@ -210,8 +182,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error handling guide - Code examples -[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.8.0...HEAD -[0.8.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...v0.8.0 +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...HEAD [0.7.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 diff --git a/src/foxnose_sdk/_version.py b/src/foxnose_sdk/_version.py index b7c7fe2..18fcd79 100644 --- a/src/foxnose_sdk/_version.py +++ b/src/foxnose_sdk/_version.py @@ -4,4 +4,4 @@ by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``). """ -__version__ = "0.8.0" +__version__ = "0.7.1" diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py index 115e650..efc5954 100644 --- a/tests/test_collection_type_aliases.py +++ b/tests/test_collection_type_aliases.py @@ -54,7 +54,7 @@ def test_top_level_package_reexports_collection_types(): def test_version_string_matches_pyproject(): """Pin the declared package version (single-sourced from _version.py, which the build backend also reads for the distribution version).""" - assert foxnose_sdk.__version__ == "0.8.0" + assert foxnose_sdk.__version__ == "0.7.1" def test_user_agent_tracks_version():