mcp2cli 3.3.0__tar.gz → 3.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp2cli
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: Turn any MCP server or OpenAPI spec into a CLI
5
5
  Author: Stephan Fitzpatrick
6
6
  Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.3.0"
3
+ version = "3.3.1"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -810,15 +810,53 @@ def build_oauth_provider(
810
810
  and the CLI hangs on the callback.
811
811
 
812
812
  We patch both by restoring ``token_expiry_time`` from a sidecar
813
- we persist in :class:`FileTokenStorage`, and by wiping the cached
814
- ``client_info`` from disk and memory whenever a refresh fails so
815
- the subsequent re-auth performs fresh Dynamic Client Registration.
813
+ we persist in :class:`FileTokenStorage`, and — only when the token
814
+ endpoint *definitively* rejects the client or grant — by wiping the
815
+ cached ``client_info`` from disk and memory so the subsequent re-auth
816
+ performs fresh Dynamic Client Registration.
817
+
818
+ Issue #59: the wipe must NOT fire on a transient refresh failure (a
819
+ brief 5xx, clock skew, a momentarily-unhappy token endpoint). Erasing
820
+ ``client.json``/``tokens.json`` on a transient blip forces a full
821
+ interactive ``authorization_code`` consent on the next call, which
822
+ permanently bricks any headless/scheduled run that has no browser. We
823
+ therefore preserve the persisted OAuth state on anything that isn't a
824
+ clean ``invalid_client``/``invalid_grant`` so a later retry can refresh
825
+ again on its own.
816
826
  """
817
827
 
818
828
  async def _initialize(self) -> None:
819
829
  await super()._initialize()
820
830
  _restore_token_expiry_from_sidecar(self.context)
821
831
 
832
+ @staticmethod
833
+ async def _refresh_failure_is_definitive(response) -> bool:
834
+ """Return True only when a failed refresh means the cached client
835
+ or grant is genuinely dead and must be discarded to recover.
836
+
837
+ Distinguishes a definitive ``invalid_client`` / ``invalid_grant``
838
+ rejection (RFC 6749 §5.2) from a transient transport/server error.
839
+ Returns False — i.e. "keep the persisted state" — for a 5xx, a
840
+ malformed body, or any other failure a later retry might survive
841
+ (issue #59).
842
+ """
843
+ status = getattr(response, "status_code", None)
844
+ # Per RFC 6749 §5.2 a 401 from the token endpoint means client
845
+ # authentication failed (invalid_client) even when the body
846
+ # carries no machine-readable error code — definitive.
847
+ if status == 401:
848
+ return True
849
+ # Anything that isn't an OAuth 400/401 error response (notably a
850
+ # 5xx) is transient: keep the cache so the next run can retry.
851
+ if status != 400:
852
+ return False
853
+ try:
854
+ error = json.loads(await response.aread()).get("error")
855
+ except Exception:
856
+ # Unparseable body on a 400 — be conservative and preserve.
857
+ return False
858
+ return error in ("invalid_client", "invalid_grant", "unauthorized_client")
859
+
822
860
  async def _handle_refresh_response(self, response) -> bool:
823
861
  # Issue #58: RFC 6749 §5.1 permits a refresh response to omit
824
862
  # refresh_token, in which case the previously issued one stays
@@ -832,15 +870,23 @@ def build_oauth_provider(
832
870
  )
833
871
  ok = await super()._handle_refresh_response(response)
834
872
  if not ok:
835
- # Refresh failed. The cached DCR client_id may have been
836
- # forgotten by the auth server too; clear it so the
837
- # subsequent 401 fallback performs a fresh registration
838
- # instead of /authorize?client_id=<stale> → opaque 500.
839
- self.context.client_info = None
840
- storage = self.context.storage
841
- if isinstance(storage, FileTokenStorage):
842
- storage.clear_client_info()
843
- storage.clear_tokens()
873
+ # Refresh failed. Only when the token endpoint *definitively*
874
+ # rejects us (invalid_client / invalid_grant) do we wipe the
875
+ # cached DCR client_id + tokens so the subsequent 401 fallback
876
+ # performs a fresh registration instead of
877
+ # /authorize?client_id=<stale> → opaque 500 (issue #54).
878
+ #
879
+ # A transient failure (5xx, blip, clock skew) must NOT erase
880
+ # the cache — doing so would force an interactive consent that
881
+ # no headless/scheduled run can complete (issue #59). Leaving
882
+ # client.json/tokens.json in place lets the next run simply
883
+ # retry the refresh.
884
+ if await self._refresh_failure_is_definitive(response):
885
+ self.context.client_info = None
886
+ storage = self.context.storage
887
+ if isinstance(storage, FileTokenStorage):
888
+ storage.clear_client_info()
889
+ storage.clear_tokens()
844
890
  return ok
845
891
  # Carry the prior refresh token forward when the server did not
846
892
  # issue a new one, then re-persist so it survives a restart.
File without changes
File without changes
File without changes