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.
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/PKG-INFO +1 -1
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/pyproject.toml +1 -1
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/src/mcp2cli/__init__.py +186 -21
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/README.md +0 -0
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.2.0 → mcp2cli-3.3.1}/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,
|
|
@@ -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
|
|
743
|
-
|
|
744
|
-
the subsequent re-auth
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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.
|
|
759
|
-
#
|
|
760
|
-
#
|
|
761
|
-
#
|
|
762
|
-
|
|
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.
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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(
|
|
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=
|
|
3000
|
+
stderr=open(log_path, "a"),
|
|
2836
3001
|
stdin=subprocess.DEVNULL,
|
|
2837
3002
|
)
|
|
2838
3003
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|