mcp2cli 3.2.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.2.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.2.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"
@@ -668,6 +668,62 @@ def _find_free_port() -> int:
668
668
  return s.getsockname()[1]
669
669
 
670
670
 
671
+
672
+
673
+ def _get_cached_redirect_uri(storage: "FileTokenStorage") -> str | None:
674
+ """Return the cached client's redirect_uri, if any.
675
+
676
+ Reads client.json from disk without async. Returns None when
677
+ no cached client exists or the redirect_uri cannot be parsed.
678
+
679
+ Used by build_oauth_provider to reuse the registered redirect
680
+ port from a prior DCR run (issue #54).
681
+ """
682
+ if not storage._client_path.exists():
683
+ return None
684
+ try:
685
+ data = json.loads(storage._client_path.read_text())
686
+ uris = data.get("redirect_uris") or []
687
+ if uris:
688
+ return uris[0]
689
+ except Exception:
690
+ pass
691
+ return None
692
+
693
+
694
+ def _port_available(host: str, port: int) -> bool:
695
+ """Check whether *port* is free on *host*.
696
+
697
+ Note: this is a best-effort probe — a TOCTOU race exists where another
698
+ process could bind the port between this check and the caller's use.
699
+ Callers must handle bind failures gracefully.
700
+ """
701
+ try:
702
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
703
+ s.bind((host, port))
704
+ return True
705
+ except OSError:
706
+ return False
707
+
708
+
709
+ def _restore_token_expiry_from_sidecar(context) -> None:
710
+ """Recompute ``token_expiry_time`` from the persisted ``expires_at`` sidecar.
711
+
712
+ The upstream SDK's ``_initialize`` reloads ``current_tokens`` from disk but
713
+ never restores ``token_expiry_time`` — the persisted ``OAuthToken`` only
714
+ carries the relative ``expires_in``. Without this, ``is_token_valid()``
715
+ treats an already-expired access token as valid on a fresh process and
716
+ sends a stale Bearer, wasting a 401 round-trip before re-auth (issues #50
717
+ and #57). Shared by the authorization-code and client-credentials
718
+ providers.
719
+ """
720
+ storage = context.storage
721
+ if isinstance(storage, FileTokenStorage) and context.current_tokens:
722
+ expires_at = storage.get_expires_at()
723
+ if expires_at is not None:
724
+ context.token_expiry_time = expires_at
725
+
726
+
671
727
  def build_oauth_provider(
672
728
  server_url: str,
673
729
  *,
@@ -708,7 +764,22 @@ def build_oauth_provider(
708
764
  ClientCredentialsOAuthProvider,
709
765
  )
710
766
 
711
- return ClientCredentialsOAuthProvider(
767
+ class _RobustClientCredentialsProvider(ClientCredentialsOAuthProvider):
768
+ """Client-credentials provider that restores expiry across restarts.
769
+
770
+ Issue #57: the upstream ``_initialize`` reloads tokens from disk
771
+ without recomputing ``token_expiry_time``, so an expired access
772
+ token looks valid after a process restart and a stale Bearer is
773
+ sent — triggering a 401 before the provider re-authenticates.
774
+ Restore the expiry from the sidecar so expired tokens are detected
775
+ proactively.
776
+ """
777
+
778
+ async def _initialize(self) -> None:
779
+ await super()._initialize()
780
+ _restore_token_expiry_from_sidecar(self.context)
781
+
782
+ return _RobustClientCredentialsProvider(
712
783
  server_url=server_url,
713
784
  storage=storage,
714
785
  client_id=client_id,
@@ -739,31 +810,93 @@ def build_oauth_provider(
739
810
  and the CLI hangs on the callback.
740
811
 
741
812
  We patch both by restoring ``token_expiry_time`` from a sidecar
742
- we persist in :class:`FileTokenStorage`, and by wiping the cached
743
- ``client_info`` from disk and memory whenever a refresh fails so
744
- 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.
745
826
  """
746
827
 
747
828
  async def _initialize(self) -> None:
748
829
  await super()._initialize()
749
- storage = self.context.storage
750
- if isinstance(storage, FileTokenStorage) and self.context.current_tokens:
751
- expires_at = storage.get_expires_at()
752
- if expires_at is not None:
753
- self.context.token_expiry_time = expires_at
830
+ _restore_token_expiry_from_sidecar(self.context)
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")
754
859
 
755
860
  async def _handle_refresh_response(self, response) -> bool:
861
+ # Issue #58: RFC 6749 §5.1 permits a refresh response to omit
862
+ # refresh_token, in which case the previously issued one stays
863
+ # valid. The upstream SDK replaces current_tokens wholesale, so
864
+ # refresh_token becomes None and every later refresh fails for
865
+ # the lack of one. Remember the old token to carry it forward.
866
+ old_refresh_token = (
867
+ self.context.current_tokens.refresh_token
868
+ if self.context.current_tokens
869
+ else None
870
+ )
756
871
  ok = await super()._handle_refresh_response(response)
757
872
  if not ok:
758
- # Refresh failed. The cached DCR client_id may have been
759
- # forgotten by the auth server too; clear it so the
760
- # subsequent 401 fallback performs a fresh registration
761
- # instead of /authorize?client_id=<stale> opaque 500.
762
- self.context.client_info = None
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()
890
+ return ok
891
+ # Carry the prior refresh token forward when the server did not
892
+ # issue a new one, then re-persist so it survives a restart.
893
+ tokens = self.context.current_tokens
894
+ if tokens is not None and not tokens.refresh_token and old_refresh_token:
895
+ tokens = tokens.model_copy(update={"refresh_token": old_refresh_token})
896
+ self.context.current_tokens = tokens
763
897
  storage = self.context.storage
764
898
  if isinstance(storage, FileTokenStorage):
765
- storage.clear_client_info()
766
- storage.clear_tokens()
899
+ await storage.set_tokens(tokens)
767
900
  return ok
768
901
 
769
902
  _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
@@ -794,9 +927,36 @@ def build_oauth_provider(
794
927
  callback_host = parsed.hostname
795
928
  port = parsed.port
796
929
  else:
797
- port = _find_free_port()
798
- callback_host = "127.0.0.1"
799
- redirect_uri = f"http://127.0.0.1:{port}/callback"
930
+ # Issue #54: When DCR is used without an explicit redirect_uri,
931
+ # _find_free_port() picks a new random port on every run. If a
932
+ # cached client.json already exists (from a prior run), its
933
+ # registered redirect_uris[0] carries the ORIGINAL port. Sending
934
+ # an auth request with a different port causes the auth server to
935
+ # reject the mismatch ("callback URL is invalid").
936
+ #
937
+ # Fix: reuse the cached redirect_uri if its port is still available.
938
+ # If the cached port is occupied (unlikely for loopback), clear the
939
+ # stale client so DCR re-registers with the fresh port.
940
+ cached_uri = _get_cached_redirect_uri(storage)
941
+ if cached_uri is not None:
942
+ from urllib.parse import urlparse as _urlparse
943
+ _parsed = _urlparse(cached_uri)
944
+ _cached_port = _parsed.port
945
+ _cached_host = _parsed.hostname or "127.0.0.1"
946
+ if _cached_port and _port_available(_cached_host, _cached_port):
947
+ callback_host = _cached_host
948
+ port = _cached_port
949
+ redirect_uri = cached_uri
950
+ else:
951
+ # Port no longer free — clear stale client and re-register
952
+ storage.clear_client_info()
953
+ port = _find_free_port()
954
+ callback_host = "127.0.0.1"
955
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
956
+ else:
957
+ port = _find_free_port()
958
+ callback_host = "127.0.0.1"
959
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
800
960
 
801
961
  client_metadata = OAuthClientMetadata(
802
962
  client_name=client_name,
@@ -846,7 +1006,7 @@ def build_oauth_provider(
846
1006
  server = HTTPServer((callback_host, port), _CallbackHandler)
847
1007
 
848
1008
  async def redirect_handler(auth_url: str) -> None:
849
- print(f"Opening browser for authorization...", file=sys.stderr)
1009
+ print("Opening browser for authorization...", file=sys.stderr)
850
1010
  print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
851
1011
  webbrowser.open(auth_url)
852
1012
 
@@ -2732,6 +2892,8 @@ def _session_meta_path(name: str) -> Path:
2732
2892
  def _session_sock_path(name: str) -> Path:
2733
2893
  return SESSIONS_DIR / f"{name}.sock"
2734
2894
 
2895
+ def _session_log_path(name: str) -> Path:
2896
+ return SESSIONS_DIR / f"{name}.log"
2735
2897
 
2736
2898
  def _session_is_alive(meta: dict) -> bool:
2737
2899
  pid = meta.get("pid")
@@ -2765,6 +2927,7 @@ def session_stop(name: str):
2765
2927
  """Stop a named session."""
2766
2928
  meta_path = _session_meta_path(name)
2767
2929
  sock_path = _session_sock_path(name)
2930
+ log_path = _session_log_path(name)
2768
2931
  if meta_path.exists():
2769
2932
  try:
2770
2933
  meta = json.loads(meta_path.read_text())
@@ -2782,6 +2945,7 @@ def session_stop(name: str):
2782
2945
  pass
2783
2946
  meta_path.unlink(missing_ok=True)
2784
2947
  sock_path.unlink(missing_ok=True)
2948
+ log_path.unlink(missing_ok=True)
2785
2949
 
2786
2950
 
2787
2951
  def session_start(
@@ -2824,6 +2988,7 @@ def session_start(
2824
2988
  }
2825
2989
  )
2826
2990
 
2991
+ log_path = _session_log_path(name)
2827
2992
  proc = subprocess.Popen(
2828
2993
  [
2829
2994
  sys.executable,
@@ -2832,7 +2997,7 @@ def session_start(
2832
2997
  ],
2833
2998
  start_new_session=True,
2834
2999
  stdout=subprocess.DEVNULL,
2835
- stderr=subprocess.DEVNULL,
3000
+ stderr=open(log_path, "a"),
2836
3001
  stdin=subprocess.DEVNULL,
2837
3002
  )
2838
3003
 
File without changes
File without changes
File without changes