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.
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/PKG-INFO +1 -1
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/pyproject.toml +1 -1
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/src/mcp2cli/__init__.py +130 -11
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/README.md +0 -0
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.2.0 → mcp2cli-3.3.0}/src/mcp2cli/py.typed +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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(
|
|
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=
|
|
2954
|
+
stderr=open(log_path, "a"),
|
|
2836
2955
|
stdin=subprocess.DEVNULL,
|
|
2837
2956
|
)
|
|
2838
2957
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|