mcp2cli 3.2.0__tar.gz → 3.3.0__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.0
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.0"
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,
@@ -746,13 +817,19 @@ def build_oauth_provider(
746
817
 
747
818
  async def _initialize(self) -> None:
748
819
  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
820
+ _restore_token_expiry_from_sidecar(self.context)
754
821
 
755
822
  async def _handle_refresh_response(self, response) -> bool:
823
+ # Issue #58: RFC 6749 §5.1 permits a refresh response to omit
824
+ # refresh_token, in which case the previously issued one stays
825
+ # valid. The upstream SDK replaces current_tokens wholesale, so
826
+ # refresh_token becomes None and every later refresh fails for
827
+ # the lack of one. Remember the old token to carry it forward.
828
+ old_refresh_token = (
829
+ self.context.current_tokens.refresh_token
830
+ if self.context.current_tokens
831
+ else None
832
+ )
756
833
  ok = await super()._handle_refresh_response(response)
757
834
  if not ok:
758
835
  # Refresh failed. The cached DCR client_id may have been
@@ -764,6 +841,16 @@ def build_oauth_provider(
764
841
  if isinstance(storage, FileTokenStorage):
765
842
  storage.clear_client_info()
766
843
  storage.clear_tokens()
844
+ return ok
845
+ # Carry the prior refresh token forward when the server did not
846
+ # issue a new one, then re-persist so it survives a restart.
847
+ tokens = self.context.current_tokens
848
+ if tokens is not None and not tokens.refresh_token and old_refresh_token:
849
+ tokens = tokens.model_copy(update={"refresh_token": old_refresh_token})
850
+ self.context.current_tokens = tokens
851
+ storage = self.context.storage
852
+ if isinstance(storage, FileTokenStorage):
853
+ await storage.set_tokens(tokens)
767
854
  return ok
768
855
 
769
856
  _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
@@ -794,9 +881,36 @@ def build_oauth_provider(
794
881
  callback_host = parsed.hostname
795
882
  port = parsed.port
796
883
  else:
797
- port = _find_free_port()
798
- callback_host = "127.0.0.1"
799
- redirect_uri = f"http://127.0.0.1:{port}/callback"
884
+ # Issue #54: When DCR is used without an explicit redirect_uri,
885
+ # _find_free_port() picks a new random port on every run. If a
886
+ # cached client.json already exists (from a prior run), its
887
+ # registered redirect_uris[0] carries the ORIGINAL port. Sending
888
+ # an auth request with a different port causes the auth server to
889
+ # reject the mismatch ("callback URL is invalid").
890
+ #
891
+ # Fix: reuse the cached redirect_uri if its port is still available.
892
+ # If the cached port is occupied (unlikely for loopback), clear the
893
+ # stale client so DCR re-registers with the fresh port.
894
+ cached_uri = _get_cached_redirect_uri(storage)
895
+ if cached_uri is not None:
896
+ from urllib.parse import urlparse as _urlparse
897
+ _parsed = _urlparse(cached_uri)
898
+ _cached_port = _parsed.port
899
+ _cached_host = _parsed.hostname or "127.0.0.1"
900
+ if _cached_port and _port_available(_cached_host, _cached_port):
901
+ callback_host = _cached_host
902
+ port = _cached_port
903
+ redirect_uri = cached_uri
904
+ else:
905
+ # Port no longer free — clear stale client and re-register
906
+ storage.clear_client_info()
907
+ port = _find_free_port()
908
+ callback_host = "127.0.0.1"
909
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
910
+ else:
911
+ port = _find_free_port()
912
+ callback_host = "127.0.0.1"
913
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
800
914
 
801
915
  client_metadata = OAuthClientMetadata(
802
916
  client_name=client_name,
@@ -846,7 +960,7 @@ def build_oauth_provider(
846
960
  server = HTTPServer((callback_host, port), _CallbackHandler)
847
961
 
848
962
  async def redirect_handler(auth_url: str) -> None:
849
- print(f"Opening browser for authorization...", file=sys.stderr)
963
+ print("Opening browser for authorization...", file=sys.stderr)
850
964
  print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
851
965
  webbrowser.open(auth_url)
852
966
 
@@ -2732,6 +2846,8 @@ def _session_meta_path(name: str) -> Path:
2732
2846
  def _session_sock_path(name: str) -> Path:
2733
2847
  return SESSIONS_DIR / f"{name}.sock"
2734
2848
 
2849
+ def _session_log_path(name: str) -> Path:
2850
+ return SESSIONS_DIR / f"{name}.log"
2735
2851
 
2736
2852
  def _session_is_alive(meta: dict) -> bool:
2737
2853
  pid = meta.get("pid")
@@ -2765,6 +2881,7 @@ def session_stop(name: str):
2765
2881
  """Stop a named session."""
2766
2882
  meta_path = _session_meta_path(name)
2767
2883
  sock_path = _session_sock_path(name)
2884
+ log_path = _session_log_path(name)
2768
2885
  if meta_path.exists():
2769
2886
  try:
2770
2887
  meta = json.loads(meta_path.read_text())
@@ -2782,6 +2899,7 @@ def session_stop(name: str):
2782
2899
  pass
2783
2900
  meta_path.unlink(missing_ok=True)
2784
2901
  sock_path.unlink(missing_ok=True)
2902
+ log_path.unlink(missing_ok=True)
2785
2903
 
2786
2904
 
2787
2905
  def session_start(
@@ -2824,6 +2942,7 @@ def session_start(
2824
2942
  }
2825
2943
  )
2826
2944
 
2945
+ log_path = _session_log_path(name)
2827
2946
  proc = subprocess.Popen(
2828
2947
  [
2829
2948
  sys.executable,
@@ -2832,7 +2951,7 @@ def session_start(
2832
2951
  ],
2833
2952
  start_new_session=True,
2834
2953
  stdout=subprocess.DEVNULL,
2835
- stderr=subprocess.DEVNULL,
2954
+ stderr=open(log_path, "a"),
2836
2955
  stdin=subprocess.DEVNULL,
2837
2956
  )
2838
2957
 
File without changes
File without changes
File without changes