mcp2cli 3.0.2__tar.gz → 3.1.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.0.2 → mcp2cli-3.1.0}/PKG-INFO +1 -1
- {mcp2cli-3.0.2 → mcp2cli-3.1.0}/pyproject.toml +1 -1
- {mcp2cli-3.0.2 → mcp2cli-3.1.0}/src/mcp2cli/__init__.py +112 -5
- {mcp2cli-3.0.2 → mcp2cli-3.1.0}/README.md +0 -0
- {mcp2cli-3.0.2 → mcp2cli-3.1.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.0.2 → mcp2cli-3.1.0}/src/mcp2cli/py.typed +0 -0
|
@@ -469,13 +469,21 @@ OAUTH_DIR = CACHE_DIR / "oauth"
|
|
|
469
469
|
|
|
470
470
|
|
|
471
471
|
class FileTokenStorage:
|
|
472
|
-
"""File-based token storage for OAuth tokens and client info.
|
|
472
|
+
"""File-based token storage for OAuth tokens and client info.
|
|
473
|
+
|
|
474
|
+
Persists an absolute ``expires_at`` timestamp alongside ``tokens.json``
|
|
475
|
+
(in ``tokens_meta.json``) so that on a fresh process the SDK can tell an
|
|
476
|
+
access token has expired and trigger a refresh, rather than blindly
|
|
477
|
+
re-sending the stale Bearer and falling through to a full re-auth that
|
|
478
|
+
reuses a possibly-forgotten DCR client_id (issue #50).
|
|
479
|
+
"""
|
|
473
480
|
|
|
474
481
|
def __init__(self, server_url: str):
|
|
475
482
|
key = hashlib.sha256(server_url.encode()).hexdigest()[:16]
|
|
476
483
|
self._dir = OAUTH_DIR / key
|
|
477
484
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
478
485
|
self._tokens_path = self._dir / "tokens.json"
|
|
486
|
+
self._tokens_meta_path = self._dir / "tokens_meta.json"
|
|
479
487
|
self._client_path = self._dir / "client.json"
|
|
480
488
|
|
|
481
489
|
async def get_tokens(self):
|
|
@@ -491,6 +499,50 @@ class FileTokenStorage:
|
|
|
491
499
|
|
|
492
500
|
async def set_tokens(self, tokens) -> None:
|
|
493
501
|
self._tokens_path.write_text(tokens.model_dump_json())
|
|
502
|
+
# Persist an absolute expiry timestamp so we can detect on a
|
|
503
|
+
# later process start that the access token has expired.
|
|
504
|
+
if tokens.expires_in is not None:
|
|
505
|
+
try:
|
|
506
|
+
meta = {"expires_at": time.time() + float(tokens.expires_in)}
|
|
507
|
+
self._tokens_meta_path.write_text(json.dumps(meta))
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
else:
|
|
511
|
+
# No expiry info; drop any stale meta sidecar.
|
|
512
|
+
try:
|
|
513
|
+
self._tokens_meta_path.unlink()
|
|
514
|
+
except FileNotFoundError:
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
def get_expires_at(self) -> float | None:
|
|
518
|
+
"""Return the absolute expiry timestamp persisted alongside tokens.
|
|
519
|
+
|
|
520
|
+
Returns None when no sidecar exists (older caches or tokens without
|
|
521
|
+
an ``expires_in`` value).
|
|
522
|
+
"""
|
|
523
|
+
if not self._tokens_meta_path.exists():
|
|
524
|
+
return None
|
|
525
|
+
try:
|
|
526
|
+
data = json.loads(self._tokens_meta_path.read_text())
|
|
527
|
+
value = data.get("expires_at")
|
|
528
|
+
return float(value) if value is not None else None
|
|
529
|
+
except Exception:
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
def clear_tokens(self) -> None:
|
|
533
|
+
"""Remove persisted tokens and their expiry sidecar."""
|
|
534
|
+
for path in (self._tokens_path, self._tokens_meta_path):
|
|
535
|
+
try:
|
|
536
|
+
path.unlink()
|
|
537
|
+
except FileNotFoundError:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
def clear_client_info(self) -> None:
|
|
541
|
+
"""Remove persisted DCR client info so a fresh registration runs."""
|
|
542
|
+
try:
|
|
543
|
+
self._client_path.unlink()
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
pass
|
|
494
546
|
|
|
495
547
|
async def get_client_info(self):
|
|
496
548
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
@@ -601,6 +653,53 @@ def build_oauth_provider(
|
|
|
601
653
|
from mcp.client.auth.oauth2 import OAuthClientProvider
|
|
602
654
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
|
603
655
|
|
|
656
|
+
class _RobustOAuthClientProvider(OAuthClientProvider):
|
|
657
|
+
"""OAuthClientProvider with cross-process expiry + stale-DCR recovery.
|
|
658
|
+
|
|
659
|
+
Fixes for issue #50 — Atlassian "Internal Server Error" on next-day
|
|
660
|
+
OAuth calls. The upstream SDK has two gaps when state is reloaded
|
|
661
|
+
from disk in a new process:
|
|
662
|
+
|
|
663
|
+
1. ``_initialize`` reloads ``current_tokens`` from storage but never
|
|
664
|
+
recomputes ``token_expiry_time`` from the bare ``OAuthToken``
|
|
665
|
+
dump (which only carries ``expires_in``). An expired access
|
|
666
|
+
token therefore looks valid, is sent, and only the 401 fallback
|
|
667
|
+
runs — skipping the refresh-token grant entirely.
|
|
668
|
+
|
|
669
|
+
2. The 401 fallback re-uses the cached DCR ``client_id`` because
|
|
670
|
+
``client_info`` was reloaded from disk; if the auth server has
|
|
671
|
+
since forgotten that registration, ``/authorize`` returns an
|
|
672
|
+
opaque 500 page rather than a clean ``invalid_client`` redirect
|
|
673
|
+
and the CLI hangs on the callback.
|
|
674
|
+
|
|
675
|
+
We patch both by restoring ``token_expiry_time`` from a sidecar
|
|
676
|
+
we persist in :class:`FileTokenStorage`, and by wiping the cached
|
|
677
|
+
``client_info`` from disk and memory whenever a refresh fails so
|
|
678
|
+
the subsequent re-auth performs fresh Dynamic Client Registration.
|
|
679
|
+
"""
|
|
680
|
+
|
|
681
|
+
async def _initialize(self) -> None:
|
|
682
|
+
await super()._initialize()
|
|
683
|
+
storage = self.context.storage
|
|
684
|
+
if isinstance(storage, FileTokenStorage) and self.context.current_tokens:
|
|
685
|
+
expires_at = storage.get_expires_at()
|
|
686
|
+
if expires_at is not None:
|
|
687
|
+
self.context.token_expiry_time = expires_at
|
|
688
|
+
|
|
689
|
+
async def _handle_refresh_response(self, response) -> bool:
|
|
690
|
+
ok = await super()._handle_refresh_response(response)
|
|
691
|
+
if not ok:
|
|
692
|
+
# Refresh failed. The cached DCR client_id may have been
|
|
693
|
+
# forgotten by the auth server too; clear it so the
|
|
694
|
+
# subsequent 401 fallback performs a fresh registration
|
|
695
|
+
# instead of /authorize?client_id=<stale> → opaque 500.
|
|
696
|
+
self.context.client_info = None
|
|
697
|
+
storage = self.context.storage
|
|
698
|
+
if isinstance(storage, FileTokenStorage):
|
|
699
|
+
storage.clear_client_info()
|
|
700
|
+
storage.clear_tokens()
|
|
701
|
+
return ok
|
|
702
|
+
|
|
604
703
|
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
605
704
|
|
|
606
705
|
if redirect_uri is not None:
|
|
@@ -700,7 +799,7 @@ def build_oauth_provider(
|
|
|
700
799
|
raise RuntimeError("No authorization code received")
|
|
701
800
|
return (_CallbackHandler.auth_code, _CallbackHandler.state)
|
|
702
801
|
|
|
703
|
-
return
|
|
802
|
+
return _RobustOAuthClientProvider(
|
|
704
803
|
server_url=server_url,
|
|
705
804
|
client_metadata=client_metadata,
|
|
706
805
|
storage=storage,
|
|
@@ -1875,6 +1974,7 @@ def build_argparse(
|
|
|
1875
1974
|
sub = subparsers.add_parser(
|
|
1876
1975
|
cmd.name,
|
|
1877
1976
|
help=escape_argparse_help(cmd.description),
|
|
1977
|
+
description=escape_argparse_help(cmd.description),
|
|
1878
1978
|
)
|
|
1879
1979
|
sub.set_defaults(_cmd=cmd)
|
|
1880
1980
|
|
|
@@ -2347,14 +2447,15 @@ async def _mcp_session(
|
|
|
2347
2447
|
# Handle resource operations
|
|
2348
2448
|
if resource_action:
|
|
2349
2449
|
await _handle_resources(
|
|
2350
|
-
session, resource_action, resource_uri, pretty, raw, toon,
|
|
2450
|
+
session, resource_action, resource_uri, pretty, raw, toon, head=head,
|
|
2351
2451
|
)
|
|
2352
2452
|
return
|
|
2353
2453
|
|
|
2354
2454
|
# Handle prompt operations
|
|
2355
2455
|
if prompt_action:
|
|
2356
2456
|
await _handle_prompts(
|
|
2357
|
-
session, prompt_action, prompt_name, prompt_arguments,
|
|
2457
|
+
session, prompt_action, prompt_name, prompt_arguments,
|
|
2458
|
+
pretty, raw, toon, head=head,
|
|
2358
2459
|
)
|
|
2359
2460
|
return
|
|
2360
2461
|
|
|
@@ -2406,7 +2507,13 @@ async def _mcp_session(
|
|
|
2406
2507
|
|
|
2407
2508
|
|
|
2408
2509
|
async def _handle_resources(
|
|
2409
|
-
session,
|
|
2510
|
+
session,
|
|
2511
|
+
action: str,
|
|
2512
|
+
uri: str | None,
|
|
2513
|
+
pretty: bool,
|
|
2514
|
+
raw: bool,
|
|
2515
|
+
toon: bool,
|
|
2516
|
+
head: int | None = None,
|
|
2410
2517
|
):
|
|
2411
2518
|
_out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
|
|
2412
2519
|
if action == "list":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|